1da56206cStracker-user<?php 2da56206cStracker-user 3da56206cStracker-usernamespace dokuwiki\plugin\annotations\test; 4da56206cStracker-user 5da56206cStracker-useruse DokuWikiTest; 6da56206cStracker-user 7da56206cStracker-user/** 8da56206cStracker-user * Storage, CRUD, permission and orphan-detection tests for the annotations 9da56206cStracker-user * helper. The helper is pure logic, so most of this needs no HTTP request. 10da56206cStracker-user * 11da56206cStracker-user * @group plugin_annotations 12da56206cStracker-user * @group plugins 13da56206cStracker-user */ 14da56206cStracker-userclass HelperTest extends DokuWikiTest 15da56206cStracker-user{ 16da56206cStracker-user protected $pluginsEnabled = ['annotations']; 17da56206cStracker-user 18da56206cStracker-user /** 19da56206cStracker-user * @return \helper_plugin_annotations 20da56206cStracker-user */ 21da56206cStracker-user protected function helper() 22da56206cStracker-user { 23da56206cStracker-user return new \helper_plugin_annotations(); 24da56206cStracker-user } 25da56206cStracker-user 26da56206cStracker-user // ----------------------------------------------------------------- 27da56206cStracker-user // Permission rules (pure functions) 28da56206cStracker-user // ----------------------------------------------------------------- 29da56206cStracker-user 30da56206cStracker-user public function testCanAnnotateRequiresLoginAndRead(): void 31da56206cStracker-user { 32da56206cStracker-user $h = $this->helper(); 33da56206cStracker-user $this->assertFalse($h->canAnnotate('', AUTH_READ), 'anonymous may not annotate'); 34da56206cStracker-user $this->assertFalse($h->canAnnotate('alice', AUTH_NONE), 'no read access → no annotate'); 35da56206cStracker-user $this->assertTrue($h->canAnnotate('alice', AUTH_READ), 'logged in + read → annotate'); 36da56206cStracker-user $this->assertTrue($h->canAnnotate('alice', AUTH_EDIT), 'edit access implies read'); 37da56206cStracker-user } 38da56206cStracker-user 39da56206cStracker-user public function testCanEditAnnotationAuthorOrAdmin(): void 40da56206cStracker-user { 41da56206cStracker-user $h = $this->helper(); 42da56206cStracker-user $ann = ['author' => 'alice']; 43da56206cStracker-user $this->assertTrue($h->canEditAnnotation($ann, 'alice', false), 'author may edit'); 44da56206cStracker-user $this->assertFalse($h->canEditAnnotation($ann, 'bob', false), 'non-author may not edit'); 45da56206cStracker-user $this->assertTrue($h->canEditAnnotation($ann, 'bob', true), 'admin may edit anyone'); 46da56206cStracker-user $this->assertFalse($h->canEditAnnotation($ann, '', true), 'anonymous never edits'); 47da56206cStracker-user } 48da56206cStracker-user 49da56206cStracker-user public function testCanEditReplyAuthorOrAdmin(): void 50da56206cStracker-user { 51da56206cStracker-user $h = $this->helper(); 52da56206cStracker-user $reply = ['author' => 'alice']; 53da56206cStracker-user $this->assertTrue($h->canEditReply($reply, 'alice', false)); 54da56206cStracker-user $this->assertFalse($h->canEditReply($reply, 'bob', false)); 55da56206cStracker-user $this->assertTrue($h->canEditReply($reply, 'bob', true)); 56da56206cStracker-user } 57da56206cStracker-user 58da56206cStracker-user public function testCanClearAdminOnly(): void 59da56206cStracker-user { 60da56206cStracker-user $h = $this->helper(); 61da56206cStracker-user $this->assertTrue($h->canClear(true)); 62da56206cStracker-user $this->assertFalse($h->canClear(false)); 63da56206cStracker-user } 64da56206cStracker-user 65da56206cStracker-user // ----------------------------------------------------------------- 66da56206cStracker-user // Annotation CRUD 67da56206cStracker-user // ----------------------------------------------------------------- 68da56206cStracker-user 69da56206cStracker-user public function testCreateGetAndStats(): void 70da56206cStracker-user { 71da56206cStracker-user $h = $this->helper(); 72da56206cStracker-user $id = 'anntest:crud'; 73da56206cStracker-user 74da56206cStracker-user $ann = $h->createAnnotation($id, ['exact' => 'hello world'], 'alice', 'A comment'); 75da56206cStracker-user $this->assertIsArray($ann); 76da56206cStracker-user $this->assertNotEmpty($ann['id']); 77da56206cStracker-user $this->assertEquals('open', $ann['status']); 78da56206cStracker-user $this->assertEquals('alice', $ann['author']); 79da56206cStracker-user 80da56206cStracker-user $this->assertCount(1, $h->getAnnotations($id)); 81da56206cStracker-user $this->assertEquals($ann['id'], $h->getAnnotation($id, $ann['id'])['id']); 82da56206cStracker-user $this->assertEquals(['total' => 1, 'open' => 1, 'resolved' => 0], $h->getStats($id)); 83da56206cStracker-user } 84da56206cStracker-user 85da56206cStracker-user public function testCreateRejectsEmptyBodyAnchorOrAuthor(): void 86da56206cStracker-user { 87da56206cStracker-user $h = $this->helper(); 88da56206cStracker-user $id = 'anntest:reject'; 89da56206cStracker-user 90da56206cStracker-user $this->assertFalse($h->createAnnotation($id, ['exact' => 'x'], 'alice', ' '), 'empty body'); 91da56206cStracker-user $this->assertFalse($h->createAnnotation($id, ['exact' => ''], 'alice', 'body'), 'empty exact'); 92da56206cStracker-user $this->assertFalse($h->createAnnotation($id, ['exact' => 'x'], '', 'body'), 'empty author'); 93da56206cStracker-user $this->assertSame([], $h->getAnnotations($id), 'nothing was stored'); 94da56206cStracker-user } 95da56206cStracker-user 96da56206cStracker-user public function testBodyAndQuoteAreLengthCapped(): void 97da56206cStracker-user { 98da56206cStracker-user $h = $this->helper(); 99da56206cStracker-user $id = 'anntest:caps'; 100da56206cStracker-user 101da56206cStracker-user $ann = $h->createAnnotation($id, ['exact' => str_repeat('q', 2000)], 'alice', str_repeat('x', 20000)); 102da56206cStracker-user $this->assertIsArray($ann); 103da56206cStracker-user $this->assertEquals(10000, mb_strlen($ann['body']), 'body capped at MAX_BODY'); 104da56206cStracker-user $this->assertEquals(1000, mb_strlen($ann['anchor']['exact']), 'quote capped at MAX_QUOTE'); 105da56206cStracker-user } 106da56206cStracker-user 107da56206cStracker-user public function testWhitespaceNormalisedInAnchor(): void 108da56206cStracker-user { 109da56206cStracker-user $h = $this->helper(); 110da56206cStracker-user $id = 'anntest:ws'; 111da56206cStracker-user $ann = $h->createAnnotation($id, ['exact' => " foo\n\t bar "], 'alice', 'b'); 112da56206cStracker-user $this->assertEquals('foo bar', $ann['anchor']['exact']); 113da56206cStracker-user } 114da56206cStracker-user 115da56206cStracker-user public function testUpdateAndDeleteAnnotation(): void 116da56206cStracker-user { 117da56206cStracker-user $h = $this->helper(); 118da56206cStracker-user $id = 'anntest:upd'; 119da56206cStracker-user $ann = $h->createAnnotation($id, ['exact' => 'foo'], 'alice', 'first'); 120da56206cStracker-user 121da56206cStracker-user $this->assertTrue($h->updateAnnotationBody($id, $ann['id'], 'second')); 122da56206cStracker-user $this->assertEquals('second', $h->getAnnotation($id, $ann['id'])['body']); 123da56206cStracker-user $this->assertFalse($h->updateAnnotationBody($id, 'nope', 'x'), 'missing id → false'); 124da56206cStracker-user $this->assertFalse($h->updateAnnotationBody($id, $ann['id'], ' '), 'empty body → false'); 125da56206cStracker-user 126da56206cStracker-user $this->assertTrue($h->deleteAnnotation($id, $ann['id'])); 127da56206cStracker-user $this->assertNull($h->getAnnotation($id, $ann['id'])); 128da56206cStracker-user $this->assertFalse($h->deleteAnnotation($id, $ann['id']), 'already gone → false'); 129da56206cStracker-user } 130da56206cStracker-user 131da56206cStracker-user public function testStatusFlow(): void 132da56206cStracker-user { 133da56206cStracker-user $h = $this->helper(); 134da56206cStracker-user $id = 'anntest:status'; 135da56206cStracker-user $ann = $h->createAnnotation($id, ['exact' => 'foo'], 'alice', 'b'); 136da56206cStracker-user 137da56206cStracker-user $this->assertTrue($h->setStatus($id, $ann['id'], 'resolved', 'bob')); 138da56206cStracker-user $resolved = $h->getAnnotation($id, $ann['id']); 139da56206cStracker-user $this->assertEquals('resolved', $resolved['status']); 140da56206cStracker-user $this->assertEquals('bob', $resolved['resolved_by']); 141da56206cStracker-user $this->assertGreaterThan(0, $resolved['resolved_at']); 142da56206cStracker-user 143da56206cStracker-user $this->assertTrue($h->setStatus($id, $ann['id'], 'open', 'bob')); 144da56206cStracker-user $reopened = $h->getAnnotation($id, $ann['id']); 145da56206cStracker-user $this->assertEquals('open', $reopened['status']); 146da56206cStracker-user $this->assertEquals('', $reopened['resolved_by']); 147da56206cStracker-user 148da56206cStracker-user $this->assertFalse($h->setStatus($id, $ann['id'], 'bogus', 'bob'), 'invalid status → false'); 149da56206cStracker-user } 150da56206cStracker-user 151da56206cStracker-user // ----------------------------------------------------------------- 152da56206cStracker-user // Reply CRUD 153da56206cStracker-user // ----------------------------------------------------------------- 154da56206cStracker-user 155da56206cStracker-user public function testReplyCrud(): void 156da56206cStracker-user { 157da56206cStracker-user $h = $this->helper(); 158da56206cStracker-user $id = 'anntest:reply'; 159da56206cStracker-user $ann = $h->createAnnotation($id, ['exact' => 'foo'], 'alice', 'b'); 160da56206cStracker-user 161da56206cStracker-user $reply = $h->addReply($id, $ann['id'], 'bob', 'a reply'); 162da56206cStracker-user $this->assertIsArray($reply); 163da56206cStracker-user $this->assertNotEmpty($reply['id']); 164da56206cStracker-user $this->assertCount(1, $h->getAnnotation($id, $ann['id'])['replies']); 165da56206cStracker-user 166da56206cStracker-user $this->assertTrue($h->updateReply($id, $ann['id'], $reply['id'], 'edited reply')); 167da56206cStracker-user $this->assertEquals('edited reply', $h->getAnnotation($id, $ann['id'])['replies'][0]['body']); 168da56206cStracker-user 169da56206cStracker-user $this->assertTrue($h->deleteReply($id, $ann['id'], $reply['id'])); 170da56206cStracker-user $this->assertCount(0, $h->getAnnotation($id, $ann['id'])['replies']); 171da56206cStracker-user 172da56206cStracker-user $this->assertFalse($h->addReply($id, 'missing-ann', 'bob', 'x'), 'reply to missing annotation → false'); 173da56206cStracker-user } 174da56206cStracker-user 175da56206cStracker-user // ----------------------------------------------------------------- 176da56206cStracker-user // Bulk maintenance 177da56206cStracker-user // ----------------------------------------------------------------- 178da56206cStracker-user 179da56206cStracker-user public function testClearResolved(): void 180da56206cStracker-user { 181da56206cStracker-user $h = $this->helper(); 182da56206cStracker-user $id = 'anntest:clearres'; 183da56206cStracker-user $keep = $h->createAnnotation($id, ['exact' => 'one'], 'alice', 'b1'); 184da56206cStracker-user $drop = $h->createAnnotation($id, ['exact' => 'two'], 'alice', 'b2'); 185da56206cStracker-user $h->setStatus($id, $drop['id'], 'resolved', 'alice'); 186da56206cStracker-user 187da56206cStracker-user $this->assertEquals(1, $h->clearResolved($id)); 188da56206cStracker-user $remaining = $h->getAnnotations($id); 189da56206cStracker-user $this->assertCount(1, $remaining); 190da56206cStracker-user $this->assertEquals($keep['id'], $remaining[0]['id']); 191da56206cStracker-user } 192da56206cStracker-user 193*72d60f2dStracker-user public function testClearResolvedAllAcrossPages(): void 194*72d60f2dStracker-user { 195*72d60f2dStracker-user $h = $this->helper(); 196*72d60f2dStracker-user $ids = ['anntest:resweepa', 'anntest:resweepb']; 197*72d60f2dStracker-user foreach ($ids as $id) { 198*72d60f2dStracker-user $h->createAnnotation($id, ['exact' => 'keep open'], 'alice', 'open'); 199*72d60f2dStracker-user $done = $h->createAnnotation($id, ['exact' => 'mark done'], 'alice', 'done'); 200*72d60f2dStracker-user $h->setStatus($id, $done['id'], 'resolved', 'alice'); 201*72d60f2dStracker-user } 202*72d60f2dStracker-user 203*72d60f2dStracker-user // clearResolvedAll() is wiki-wide and the shared test data dir may carry 204*72d60f2dStracker-user // resolved annotations from earlier tests, so assert the seeded ones are 205*72d60f2dStracker-user // included (>= 2) rather than an exact global total, and verify each of 206*72d60f2dStracker-user // our pages keeps exactly its one open annotation. 207*72d60f2dStracker-user $removed = $h->clearResolvedAll(); 208*72d60f2dStracker-user $this->assertGreaterThanOrEqual(2, $removed, 'at least the two seeded resolved are cleared'); 209*72d60f2dStracker-user foreach ($ids as $id) { 210*72d60f2dStracker-user $remaining = $h->getAnnotations($id); 211*72d60f2dStracker-user $this->assertCount(1, $remaining, $id . ' keeps only the open annotation'); 212*72d60f2dStracker-user $this->assertEquals('open', $remaining[0]['body'], $id . ' kept the right annotation'); 213*72d60f2dStracker-user } 214*72d60f2dStracker-user } 215*72d60f2dStracker-user 216da56206cStracker-user // ----------------------------------------------------------------- 217da56206cStracker-user // Orphan detection against a rendered page 218da56206cStracker-user // ----------------------------------------------------------------- 219da56206cStracker-user 220da56206cStracker-user public function testFindAndClearOrphanedAgainstRenderedPage(): void 221da56206cStracker-user { 222da56206cStracker-user $id = 'anntest:orphan'; 223da56206cStracker-user saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 224da56206cStracker-user 225da56206cStracker-user $h = $this->helper(); 226da56206cStracker-user $present = $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 227da56206cStracker-user $gone = $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 228da56206cStracker-user 229da56206cStracker-user $orphanIds = array_map(static function ($a) { 230da56206cStracker-user return $a['id']; 231da56206cStracker-user }, $h->findOrphaned($id)); 232da56206cStracker-user 233da56206cStracker-user $this->assertContains($gone['id'], $orphanIds, 'a missing quote is orphaned'); 234da56206cStracker-user $this->assertNotContains($present['id'], $orphanIds, 'a present quote is not orphaned'); 235da56206cStracker-user 236da56206cStracker-user $this->assertEquals(1, $h->clearOrphaned($id), 'only the orphan is cleared'); 237da56206cStracker-user $remaining = $h->getAnnotations($id); 238da56206cStracker-user $this->assertCount(1, $remaining); 239da56206cStracker-user $this->assertEquals($present['id'], $remaining[0]['id']); 240da56206cStracker-user } 2419fd890c3Stracker-user 2429fd890c3Stracker-user // ----------------------------------------------------------------- 2439fd890c3Stracker-user // Admin overview (enumeration & counts) 2449fd890c3Stracker-user // ----------------------------------------------------------------- 2459fd890c3Stracker-user 2469fd890c3Stracker-user public function testGetAnnotatedPages(): void 2479fd890c3Stracker-user { 2489fd890c3Stracker-user $h = $this->helper(); 2499fd890c3Stracker-user $h->createAnnotation('anntest:listone', ['exact' => 'foo'], 'alice', 'b'); 2509fd890c3Stracker-user $h->createAnnotation('anntest:nested:listtwo', ['exact' => 'bar'], 'alice', 'b'); 2519fd890c3Stracker-user 2529fd890c3Stracker-user // a page whose only annotation is then deleted leaves an empty file and 2539fd890c3Stracker-user // must NOT be listed 2549fd890c3Stracker-user $gone = $h->createAnnotation('anntest:emptied', ['exact' => 'baz'], 'alice', 'b'); 2559fd890c3Stracker-user $h->deleteAnnotation('anntest:emptied', $gone['id']); 2569fd890c3Stracker-user 2579fd890c3Stracker-user $pages = $h->getAnnotatedPages(); 2589fd890c3Stracker-user $this->assertContains('anntest:listone', $pages); 2599fd890c3Stracker-user $this->assertContains('anntest:nested:listtwo', $pages); 2609fd890c3Stracker-user $this->assertNotContains('anntest:emptied', $pages, 'emptied page is excluded'); 2619fd890c3Stracker-user } 2629fd890c3Stracker-user 2639fd890c3Stracker-user public function testPageCountsSplitsNormalAndOrphaned(): void 2649fd890c3Stracker-user { 2659fd890c3Stracker-user $id = 'anntest:counts'; 2669fd890c3Stracker-user saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 2679fd890c3Stracker-user 2689fd890c3Stracker-user $h = $this->helper(); 2699fd890c3Stracker-user $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 2709fd890c3Stracker-user $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 2719fd890c3Stracker-user 2729fd890c3Stracker-user $this->assertEquals( 273*72d60f2dStracker-user ['total' => 2, 'normal' => 1, 'resolved' => 0, 'orphaned' => 1], 274*72d60f2dStracker-user $h->pageCounts($id) 275*72d60f2dStracker-user ); 276*72d60f2dStracker-user } 277*72d60f2dStracker-user 278*72d60f2dStracker-user public function testPageCountsCountsResolvedAcrossFacets(): void 279*72d60f2dStracker-user { 280*72d60f2dStracker-user $id = 'anntest:countsres'; 281*72d60f2dStracker-user saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 282*72d60f2dStracker-user 283*72d60f2dStracker-user $h = $this->helper(); 284*72d60f2dStracker-user // present + resolved → counted in both normal and resolved 285*72d60f2dStracker-user $present = $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 286*72d60f2dStracker-user $h->setStatus($id, $present['id'], 'resolved', 'alice'); 287*72d60f2dStracker-user // orphaned + resolved → counted in both orphaned and resolved 288*72d60f2dStracker-user $gone = $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 289*72d60f2dStracker-user $h->setStatus($id, $gone['id'], 'resolved', 'alice'); 290*72d60f2dStracker-user 291*72d60f2dStracker-user $this->assertEquals( 292*72d60f2dStracker-user ['total' => 2, 'normal' => 1, 'resolved' => 2, 'orphaned' => 1], 2939fd890c3Stracker-user $h->pageCounts($id) 2949fd890c3Stracker-user ); 2959fd890c3Stracker-user } 2969fd890c3Stracker-user 2979fd890c3Stracker-user public function testPageCountsEmptyForUnannotatedPage(): void 2989fd890c3Stracker-user { 2999fd890c3Stracker-user $h = $this->helper(); 3009fd890c3Stracker-user $this->assertEquals( 301*72d60f2dStracker-user ['total' => 0, 'normal' => 0, 'resolved' => 0, 'orphaned' => 0], 3029fd890c3Stracker-user $h->pageCounts('anntest:never') 3039fd890c3Stracker-user ); 3049fd890c3Stracker-user } 3059fd890c3Stracker-user 3069fd890c3Stracker-user public function testClearOrphanedAllAcrossPages(): void 3079fd890c3Stracker-user { 3089fd890c3Stracker-user $h = $this->helper(); 3099fd890c3Stracker-user $ids = ['anntest:sweepa', 'anntest:sweepb']; 3109fd890c3Stracker-user foreach ($ids as $id) { 3119fd890c3Stracker-user saveWikiText($id, 'Hello world, this is a wiki page about cats.', 'setup'); 3129fd890c3Stracker-user $h->createAnnotation($id, ['exact' => 'wiki page about cats'], 'alice', 'present'); 3139fd890c3Stracker-user $h->createAnnotation($id, ['exact' => 'text that is not here'], 'alice', 'gone'); 3149fd890c3Stracker-user } 3159fd890c3Stracker-user 3169fd890c3Stracker-user // clearOrphanedAll() is wiki-wide and the shared test data dir may carry 3179fd890c3Stracker-user // annotated pages from earlier tests, so assert the seeded orphans are 3189fd890c3Stracker-user // included (>= 2) rather than an exact global total, and verify each of 3199fd890c3Stracker-user // our pages keeps exactly its one present annotation. 3209fd890c3Stracker-user $removed = $h->clearOrphanedAll(); 3219fd890c3Stracker-user $this->assertGreaterThanOrEqual(2, $removed, 'at least the two seeded orphans are cleared'); 3229fd890c3Stracker-user foreach ($ids as $id) { 3239fd890c3Stracker-user $remaining = $h->getAnnotations($id); 3249fd890c3Stracker-user $this->assertCount(1, $remaining, $id . ' keeps only the present annotation'); 3259fd890c3Stracker-user $this->assertEquals('present', $remaining[0]['body'], $id . ' kept the right annotation'); 3269fd890c3Stracker-user $this->assertSame(0, $h->pageCounts($id)['orphaned'], $id . ' has no orphans left'); 3279fd890c3Stracker-user } 3289fd890c3Stracker-user } 329da56206cStracker-user} 330