فهرست منبع

#5690 Unit tests: posts module

Aleksandr Trofimov 1 ماه پیش
والد
کامیت
df77fa6831

+ 10 - 5
modules/boonex/profiler/classes/BxProfiler.php

@@ -181,11 +181,16 @@ class BxProfiler extends BxDol
     function logPageOpen ($iTime)
     {
         $s  = $this->_logBegin ('LONG PAGE OPEN: ' . $this->_formatTime($iTime));
-        $s .= "Request method: " . $_SERVER['REQUEST_METHOD'] . "\n";
-        $s .= "Query string: " . $_SERVER['QUERY_STRING'] . "\n";
-        $s .= "Request URI: " . $_SERVER['REQUEST_URI'] . "\n";
-        $s .= "Script name: " . $_SERVER['SCRIPT_NAME'] . "\n";
-        $s .= "PHP self: " . $_SERVER['PHP_SELF'] . "\n";
+        if (isset($_SERVER['REQUEST_METHOD']))
+            $s .= "Request method: " . $_SERVER['REQUEST_METHOD'] . "\n";
+        if (isset($_SERVER['QUERY_STRING']))
+            $s .= "Query string: " . $_SERVER['QUERY_STRING'] . "\n";
+        if (isset($_SERVER['REQUEST_URI']))
+            $s .= "Request URI: " . $_SERVER['REQUEST_URI'] . "\n";
+        if (isset($_SERVER['SCRIPT_NAME']))
+            $s .= "Script name: " . $_SERVER['SCRIPT_NAME'] . "\n";
+        if (isset($_SERVER['PHP_SELF']))
+            $s .= "PHP self: " . $_SERVER['PHP_SELF'] . "\n";
         if (getParam('bx_profiler_long_page_debug'))
             $s .= "All server vars: \n" . print_r ($_SERVER, true);
         $s .= $this->_logEnd();

+ 47 - 0
tests/bootstrap.php

@@ -78,6 +78,51 @@ class BxDolTestCase extends \PHPUnit\Framework\TestCase
         return $oModule;
     }
 
+    protected function bxRequirePosts()
+    {
+        bx_import('BxDolModule');
+        $oModule = BxDolModule::getInstance('bx_posts');
+        if (!$oModule)
+            $this->markTestSkipped('bx_posts module is not installed.');
+
+        if (!BxDolDb::getInstance()->isTableExists('bx_posts_posts'))
+            $this->markTestSkipped('bx_posts module is not installed (table bx_posts_posts is missing).');
+
+        return $oModule;
+    }
+
+    protected function bxCallProtected($o, $sMethod, ...$aArgs)
+    {
+        $oMethod = new ReflectionMethod($o, $sMethod);
+        $oMethod->setAccessible(true);
+        return $oMethod->invokeArgs($o, $aArgs);
+    }
+
+    protected function bxCallProtectedArgs($o, $sMethod, array $aArgs)
+    {
+        $oMethod = new ReflectionMethod($o, $sMethod);
+        $oMethod->setAccessible(true);
+        return $oMethod->invokeArgs($o, $aArgs);
+    }
+
+    protected function bxSetProtected($o, $sProperty, $mixedValue, $sClass = null)
+    {
+        $oProperty = new ReflectionProperty($sClass ?: $o, $sProperty);
+        $oProperty->setAccessible(true);
+        $oProperty->setValue($o, $mixedValue);
+    }
+
+    protected function bxModuleInstances(BxDolModule $oModule, ?array $aReplace = null): array
+    {
+        $oProperty = new ReflectionProperty(BxDolModule::class, '_aInstancesStorage');
+        $oProperty->setAccessible(true);
+        $aCurrent = $oProperty->getValue($oModule) ?: [];
+        if ($aReplace !== null)
+            $oProperty->setValue($oModule, $aReplace);
+
+        return $aCurrent;
+    }
+
     function bxMockGet ($sClass, $aModule = array(), $bDisableContructor = false)
     {
         if ($aModule)
@@ -109,3 +154,5 @@ class BxDolTestCase extends \PHPUnit\Framework\TestCase
     }
 
 }
+
+require_once dirname(__FILE__) . '/units/modules/boonex/posts/BxPostsTestCase.php';

+ 83 - 0
tests/units/modules/boonex/posts/BxPostsTestCase.php

@@ -0,0 +1,83 @@
+<?php
+
+/**
+ * Shared helpers for Posts-backed content-module unit tests.
+ */
+abstract class BxPostsTestCase extends BxDolTestCase
+{
+    protected $_oModule;
+    protected $_aModuleInstancesBackup;
+    protected $_iProfileIdBackup;
+
+    protected function setUp(): void
+    {
+        $this->_oModule = $this->bxRequirePosts();
+        $this->_oModule->_oDb;
+        $this->_aModuleInstancesBackup = $this->bxModuleInstances($this->_oModule);
+
+        foreach (['SearchResult', 'FormEntry', 'FormsEntryHelper'] as $sClass)
+            bx_import($sClass, $this->_oModule->_aModule);
+
+        $oProfileId = new ReflectionProperty(BxBaseModGeneralModule::class, '_iProfileId');
+        $oProfileId->setAccessible(true);
+        $this->_iProfileIdBackup = $oProfileId->getValue($this->_oModule);
+    }
+
+    protected function bxTemplate()
+    {
+        $oTemplate = $this->_oModule->_oTemplate;
+        if ($oTemplate instanceof BxDolModuleProxy) {
+            $oProperty = new ReflectionProperty(BxDolModuleProxy::class, '_oProxifiedObject');
+            $oProperty->setAccessible(true);
+            $oTemplate = $oProperty->getValue($oTemplate);
+        }
+
+        return $oTemplate;
+    }
+
+    protected function tearDown(): void
+    {
+        if ($this->_oModule && $this->_aModuleInstancesBackup !== null)
+            $this->bxModuleInstances($this->_oModule, $this->_aModuleInstancesBackup);
+
+        if ($this->_oModule && $this->_iProfileIdBackup !== null)
+            $this->bxSetProtected($this->_oModule, '_iProfileId', $this->_iProfileIdBackup, BxBaseModGeneralModule::class);
+    }
+
+    protected function bxReplaceDb($oDb): void
+    {
+        $a = $this->_aModuleInstancesBackup;
+        $a['_oDb'] = $oDb;
+        $this->bxModuleInstances($this->_oModule, $a);
+    }
+
+    protected function bxSamplePost(array $aOverride = []): array
+    {
+        return array_merge([
+            'id' => 15,
+            'author' => 10,
+            'added' => 1700000000,
+            'changed' => 1700000100,
+            'published' => 1700000000,
+            'title' => 'Sample post',
+            'abstract' => 'Abstract',
+            'text' => 'Body text',
+            'allow_view_to' => 3,
+            'cf' => 1,
+            'status' => 'active',
+            'status_admin' => 'active',
+            'allow_comments' => 1,
+            'thumb' => 0,
+            'thumb_data' => '',
+            'views' => 0,
+            'votes' => 0,
+            'rvotes' => 0,
+            'score' => 0,
+            'reports' => 0,
+            'comments' => 0,
+            'featured' => 0,
+            'labels' => '',
+            'location' => '',
+        ], $aOverride);
+    }
+}

+ 125 - 0
tests/units/modules/boonex/posts/classes/BxPostsConfigTest.php

@@ -0,0 +1,125 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Content-module config as implemented by Posts (CNF, prefixes, URLs, attach flags).
+ */
+class BxPostsConfigTest extends BxPostsTestCase
+{
+    public function testCnfKeysForContentModule()
+    {
+        $CNF = $this->_oModule->_oConfig->CNF;
+
+        $this->assertSame('bx_posts_posts', $CNF['TABLE_ENTRIES']);
+        $this->assertSame('id', $CNF['FIELD_ID']);
+        $this->assertSame('author', $CNF['FIELD_AUTHOR']);
+        $this->assertSame('title', $CNF['FIELD_TITLE']);
+        $this->assertSame('text', $CNF['FIELD_TEXT']);
+        $this->assertSame('published', $CNF['FIELD_PUBLISHED']);
+        $this->assertSame('allow_comments', $CNF['FIELD_ALLOW_COMMENTS']);
+        $this->assertSame('view-post', $CNF['URI_VIEW_ENTRY']);
+        $this->assertSame('create-post', $CNF['URI_ADD_ENTRY']);
+        $this->assertSame('edit-post', $CNF['URI_EDIT_ENTRY']);
+        $this->assertSame('file-alt', $CNF['ICON']);
+        $this->assertTrue($CNF['PARAM_LINKS_ENABLED']);
+        $this->assertTrue($CNF['PARAM_MULTICAT_ENABLED']);
+        $this->assertTrue($CNF['PARAM_POLL_ENABLED']);
+    }
+
+    public function testAttachLinksAndTimelineAttachments()
+    {
+        $oConfig = $this->_oModule->_oConfig;
+
+        $this->assertTrue($oConfig->isAttachLinks());
+        $this->assertTrue($oConfig->isAttachmentsInTimeline());
+        $this->assertTrue($oConfig->isAutoApprove());
+        $this->assertSame(3600, $oConfig->getDpnTime());
+    }
+
+    public function testPrefixesAndJsObjects()
+    {
+        $oConfig = $this->_oModule->_oConfig;
+
+        $this->assertSame('bx-posts', $oConfig->getPrefix('style'));
+        $this->assertSame('BxPostsPolls', $oConfig->getJsClass('poll'));
+        $this->assertSame('oBxPostsPolls', $oConfig->getJsObject('poll'));
+        $this->assertSame('oBxPostsPollsAdd', $oConfig->getJsObjectPoll(0));
+        $this->assertSame('oBxPostsPollsEdit12', $oConfig->getJsObjectPoll(12));
+        $this->assertSame('bx_posts_common', $oConfig->getGridObject('common'));
+        $this->assertSame('bx_posts_administration', $oConfig->getGridObject('administration'));
+        $this->assertSame('', $oConfig->getGridObject('missing'));
+        $this->assertSame('', $oConfig->getJsClass('missing'));
+    }
+
+    public function testHtmlIds()
+    {
+        $oConfig = $this->_oModule->_oConfig;
+
+        $this->assertSame('bx-posts-attach-link-popup', $oConfig->getHtmlIds('attach_link_popup'));
+        $this->assertSame('bx-posts-add-poll-popup', $oConfig->getHtmlIds('add_poll_popup'));
+        $this->assertSame('', $oConfig->getHtmlIds('missing'));
+        $this->assertIsArray($oConfig->getHtmlIds());
+    }
+
+    #[DataProvider('providerForEqualUrls')]
+    public function testIsEqualUrls($sLeft, $sRight, $bOut)
+    {
+        $this->assertSame($bOut, $this->_oModule->_oConfig->isEqualUrls($sLeft, $sRight));
+    }
+
+    static public function providerForEqualUrls()
+    {
+        return [
+            ['page.php?i=view-post', 'page.php?i=view-post', true],
+            ['page.php?i=view-post/', 'page.php?i=view-post', true],
+            ['page.php?i=view-post', 'page.php?i=view-post&id=1', true],
+            ['page.php?i=view-post', 'page.php?i=posts-home', false],
+        ];
+    }
+
+    #[DataProvider('providerForEntryUri')]
+    public function testGetEntryUri($sAction, $sUri)
+    {
+        $this->assertSame($sUri, $this->_oModule->_oConfig->getEntryUri($sAction));
+    }
+
+    static public function providerForEntryUri()
+    {
+        return [
+            ['view', 'view-post'],
+            ['add', 'create-post'],
+            ['edit', 'edit-post'],
+            ['missing', ''],
+        ];
+    }
+
+    public function testGetViewEntryUrl()
+    {
+        $oConfig = $this->_oModule->_oConfig;
+
+        $this->assertSame('', $oConfig->getViewEntryUrl('x'));
+        $this->assertSame('', $oConfig->getViewEntryUrl([]));
+
+        $sFromId = $oConfig->getViewEntryUrl(15);
+        $sFromRow = $oConfig->getViewEntryUrl(['id' => 15]);
+        $this->assertNotSame('', $sFromId);
+        $this->assertSame($sFromId, $sFromRow);
+    }
+
+    public function testGetImageUrlEmpty()
+    {
+        $this->assertSame('', $this->_oModule->_oConfig->getImageUrl(0, ['OBJECT_IMAGES_TRANSCODER_PREVIEW']));
+        $this->assertSame('', $this->_oModule->_oConfig->getImageUrl(1, []));
+    }
+
+    public function testPregPatterns()
+    {
+        $oConfig = $this->_oModule->_oConfig;
+
+        $this->assertSame(1, preg_match($oConfig->getPregPattern('meta_title'), '<title>Hello</title>', $a));
+        $this->assertSame('Hello', $a[1]);
+
+        $this->assertSame(1, preg_match($oConfig->getPregPattern('url'), 'https://example.com/path?q=1'));
+    }
+}

+ 97 - 0
tests/units/modules/boonex/posts/classes/BxPostsDbTest.php

@@ -0,0 +1,97 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Content-module DB helpers via Posts (SQL construction, no row writes).
+ */
+class BxPostsDbTest extends BxPostsTestCase
+{
+    #[DataProvider('providerForEbsiLike')]
+    public function testGetEbsiLike($sIn, $sNeedle)
+    {
+        $s = $this->bxCallProtected($this->_oModule->_oDb, '_getEbsiLike', $sIn);
+        $this->assertStringContainsString($sNeedle, $s);
+    }
+
+    static public function providerForEbsiLike()
+    {
+        return [
+            ['hello', '%hello%'],
+            ['hello world', '%hello%world%'],
+            ['a   b', '%a%b%'],
+        ];
+    }
+
+    public function testSearchByIdsAddsActiveStatusAndOperators()
+    {
+        $oDb = $this->_oModule->_oDb;
+        $aParams = [
+            'search_params' => [
+                'title' => ['operator' => 'like', 'value' => 'hello world'],
+                'author' => ['operator' => 'in', 'value' => [10, 11]],
+                'cat' => ['operator' => '=', 'value' => 3],
+            ],
+            'per_page' => 5,
+            'start' => 10,
+            'show_all_content' => true,
+        ];
+        $aMethod = ['name' => 'getAll', 'params' => [0 => 'query']];
+        $sSelect = $sJoin = $sWhere = $sOrder = $sLimit = '';
+        $aArgs = [$aParams, &$aMethod, &$sSelect, &$sJoin, &$sWhere, &$sOrder, &$sLimit];
+        $this->bxCallProtectedArgs($oDb, '_getEntriesBySearchIds', $aArgs);
+
+        $this->assertSame('getColumn', $aMethod['name']);
+        $this->assertStringContainsString('`bx_posts_posts`.`status`=\'active\'', $sWhere);
+        $this->assertStringContainsString('`bx_posts_posts`.`status_admin`=\'active\'', $sWhere);
+        $this->assertStringContainsString('LIKE', $sWhere);
+        $this->assertStringContainsString('%hello%world%', $sWhere);
+        $this->assertStringContainsString(' IN (', $sWhere);
+        $this->assertStringContainsString('`cat` =', $sWhere);
+        $this->assertSame(3, $aMethod['params'][1]['cat']);
+        $this->assertNotEmpty($sLimit);
+        $this->assertStringNotContainsString('INNER JOIN `sys_profiles`', $sJoin);
+    }
+
+    public function testSearchByIdsLikeArrayAndInvalidOperator()
+    {
+        $oDb = $this->_oModule->_oDb;
+        $aParams = [
+            'search_params' => [
+                'title' => ['operator' => 'like', 'value' => ['one', 'two']],
+                'cf' => ['operator' => 'and', 'value' => [1, 3]],
+            ],
+            'show_all_content' => true,
+        ];
+        $aMethod = ['name' => 'getAll', 'params' => [0 => 'query']];
+        $sSelect = $sJoin = $sWhere = $sOrder = $sLimit = '';
+        $aArgs = [$aParams, &$aMethod, &$sSelect, &$sJoin, &$sWhere, &$sOrder, &$sLimit];
+        $this->bxCallProtectedArgs($oDb, '_getEntriesBySearchIds', $aArgs);
+
+        $this->assertStringContainsString(' OR ', $sWhere);
+        $this->assertStringContainsString(' & ', $sWhere);
+
+        $this->expectException(Exception::class);
+        $aParamsBad = [
+            'search_params' => [
+                'title' => ['operator' => 'OR 1=1', 'value' => 'x'],
+            ],
+            'show_all_content' => true,
+        ];
+        $aArgsBad = [$aParamsBad, &$aMethod, &$sSelect, &$sJoin, &$sWhere, &$sOrder, &$sLimit];
+        $this->bxCallProtectedArgs($oDb, '_getEntriesBySearchIds', $aArgsBad);
+    }
+
+    public function testUpdateEntriesByRejectsEmpty()
+    {
+        $this->assertFalse($this->_oModule->_oDb->updateEntriesBy([], ['id' => 1]));
+        $this->assertFalse($this->_oModule->_oDb->updateEntriesBy(['status' => 'hidden'], []));
+    }
+
+    public function testLinksHelpersWhenAttachEnabled()
+    {
+        $this->assertTrue($this->_oModule->_oConfig->isAttachLinks());
+        $this->assertIsArray($this->_oModule->_oDb->getLinks(0));
+        $this->assertIsArray($this->_oModule->_oDb->getUnusedLinks(0));
+    }
+}

+ 122 - 0
tests/units/modules/boonex/posts/classes/BxPostsFormsTest.php

@@ -0,0 +1,122 @@
+<?php
+
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
+
+/**
+ * Content-module form helper and entry-form methods via Posts.
+ */
+#[AllowMockObjectsWithoutExpectations]
+class BxPostsFormsTest extends BxPostsTestCase
+{
+    public function testPrepareResponsePlainAndJson()
+    {
+        $o = new BxPostsFormsEntryHelper($this->_oModule);
+
+        $this->assertSame('ok', $this->bxCallProtected($o, 'prepareResponse', 'ok', false));
+        $this->assertSame(
+            ['msg' => 'ok', '_dt' => 'json'],
+            $this->bxCallProtected($o, 'prepareResponse', 'ok', true)
+        );
+        $this->assertSame(
+            ['code' => 1, '_dt' => 'json', 'reload' => 1],
+            $this->bxCallProtected($o, 'prepareResponse', 1, true, 'code', ['reload' => 1])
+        );
+    }
+
+    public function testRedirectFromContextWhenPublicPrivacy()
+    {
+        $o = new BxPostsFormsEntryHelper($this->_oModule);
+        $aPost = $this->bxSamplePost(['allow_view_to' => 3]);
+
+        $this->assertFalse($this->bxCallProtected($o, '_getRedirectFromContext', 'add', $aPost));
+    }
+
+    public function testPrepareCustomRedirectUrlReplacesMarkers()
+    {
+        $o = new BxPostsFormsEntryHelper($this->_oModule);
+        $s = $this->bxCallProtected($o, 'prepareCustomRedirectUrl', 'page.php?i=view-post&id={content_id}&m={module}', $this->bxSamplePost());
+
+        $this->assertStringNotContainsString('{content_id}', $s);
+        $this->assertStringNotContainsString('{module}', $s);
+        $this->assertStringContainsString('bx_posts', $s);
+    }
+
+    public function testViewDataTextMissingContent()
+    {
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturn(false);
+        $this->bxReplaceDb($oDb);
+
+        $o = new BxPostsFormsEntryHelper($this->_oModule);
+        [$oProfile, $aContent] = $this->bxCallProtected($o, '_getProfileAndContentData', 15);
+        $this->assertFalse($oProfile);
+        $this->assertFalse($aContent);
+    }
+
+    public function testProcessLinksSkipsMissingField()
+    {
+        $oForm = $this->bxFormDouble();
+        $oForm->aInputs = [];
+
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->expects($this->never())->method('saveLink');
+        $this->bxReplaceDb($oDb);
+
+        $this->assertTrue($oForm->processLinks('link', 15));
+    }
+
+    public function testProcessLinksSavesEachId()
+    {
+        $oForm = $this->bxFormDouble(['1', '2']);
+        $oForm->aInputs = ['link' => ['name' => 'link']];
+
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->expects($this->exactly(2))->method('saveLink')->willReturnCallback(function ($iContentId, $iLinkId) {
+            $this->assertSame(15, $iContentId);
+            $this->assertContains((string)$iLinkId, ['1', '2']);
+            return true;
+        });
+        $this->bxReplaceDb($oDb);
+
+        $oForm->processLinks('link', 15);
+    }
+
+    public function testCoverGhostTemplateVars()
+    {
+        $oForm = $this->bxFormDouble();
+        $oForm->aInputs = [
+            'covers' => [
+                'name' => 'covers',
+                'content_id' => 15,
+            ],
+        ];
+
+        $a = $this->bxCallProtected($oForm, '_getCoverGhostTmplVars', $this->bxSamplePost(['thumb' => 9]));
+        $this->assertSame('covers', $a['name']);
+        $this->assertSame(15, $a['content_id']);
+        $this->assertSame('post-text', $a['editor_id']);
+        $this->assertSame(9, $a['thumb_id']);
+        $this->assertSame('thumb', $a['name_thumb']);
+    }
+
+    protected function bxFormDouble($mixedClean = null): BxPostsFormEntry
+    {
+        $oForm = new class($this->_oModule) extends BxPostsFormEntry {
+            public $mixedCleanValue;
+
+            public function __construct($oModule)
+            {
+                $this->MODULE = 'bx_posts';
+                $this->_oModule = $oModule;
+                $this->aInputs = [];
+            }
+
+            public function getCleanValue($sName)
+            {
+                return $this->mixedCleanValue;
+            }
+        };
+        $oForm->mixedCleanValue = $mixedClean;
+        return $oForm;
+    }
+}

+ 155 - 0
tests/units/modules/boonex/posts/classes/BxPostsModulePermissionsTest.php

@@ -0,0 +1,155 @@
+<?php
+
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Content-module permission and status checks via Posts.
+ */
+#[AllowMockObjectsWithoutExpectations]
+class BxPostsModulePermissionsTest extends BxPostsTestCase
+{
+    #[DataProvider('providerForEntryAuthor')]
+    public function testIsEntryAuthor($iAuthor, $iViewer, $bOut)
+    {
+        $aPost = $this->bxSamplePost(['author' => $iAuthor]);
+        $this->assertSame($bOut, $this->_oModule->isEntryAuthor($aPost, $iViewer));
+    }
+
+    static public function providerForEntryAuthor()
+    {
+        return [
+            [10, 10, true],
+            [-10, 10, true],
+            [10, 11, false],
+            [0, 10, false],
+        ];
+    }
+
+    public function testIsEntryActiveForAuthorIgnoresStatus()
+    {
+        $iViewer = (int)bx_get_logged_profile_id();
+        $aPost = $this->bxSamplePost([
+            'author' => $iViewer,
+            'status' => 'hidden',
+            'status_admin' => 'pending',
+        ]);
+
+        $this->assertTrue($this->_oModule->isEntryActive($aPost));
+    }
+
+    public function testIsEntryActiveHiddenForStranger()
+    {
+        if ($this->_oModule->_isModerator())
+            $this->markTestSkipped('Moderator always sees hidden entries.');
+
+        $aPost = $this->bxSamplePost(['author' => 10, 'status' => 'hidden']);
+        if ($this->_oModule->isEntryAuthor($aPost, (int)bx_get_logged_profile_id()))
+            $this->markTestSkipped('Logged profile is the sample author.');
+
+        $this->assertFalse($this->_oModule->isEntryActive($aPost));
+    }
+
+    public function testIsEntryActivePendingAdminForStranger()
+    {
+        if ($this->_oModule->_isModerator())
+            $this->markTestSkipped('Moderator always sees hidden entries.');
+        $aPost = $this->bxSamplePost(['author' => 10, 'status_admin' => 'pending']);
+        if ($this->_oModule->isEntryAuthor($aPost, (int)bx_get_logged_profile_id()))
+            $this->markTestSkipped('Logged profile is the sample author.');
+
+        $this->assertFalse($this->_oModule->isEntryActive($aPost));
+    }
+
+    public function testIsEntryActiveWhenPublished()
+    {
+        $this->assertTrue($this->_oModule->isEntryActive($this->bxSamplePost(['author' => 10])));
+    }
+
+    public function testCheckAllowedApproveRejectsEmptyAndNonPending()
+    {
+        $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedApprove([]));
+        $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedApprove($this->bxSamplePost()));
+        $this->assertFalse($this->_oModule->isAllowedApprove($this->bxSamplePost()));
+    }
+
+    public function testCheckAllowedApprovePendingWithoutModerator()
+    {
+        $this->bxSetProtected($this->_oModule, '_iProfileId', 99, BxBaseModGeneralModule::class);
+        $aPost = $this->bxSamplePost(['status_admin' => BX_BASE_MOD_GENERAL_STATUS_PENDING]);
+
+        $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedApprove($aPost));
+    }
+
+    public function testCheckAllowedEditAllowsAuthor()
+    {
+        $this->bxSetProtected($this->_oModule, '_iProfileId', 10, BxBaseModGeneralModule::class);
+        $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedEdit($this->bxSamplePost()));
+    }
+
+    public function testCheckAllowedEditDeniesStranger()
+    {
+        $this->bxSetProtected($this->_oModule, '_iProfileId', 99, BxBaseModGeneralModule::class);
+        $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedEdit($this->bxSamplePost()));
+    }
+
+    public function testCheckAllowedBrowseDefaultAllowed()
+    {
+        $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedBrowse());
+        $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->serviceCheckAllowed('Browse'));
+    }
+
+    public function testServiceCheckAllowedUnknownAction()
+    {
+        $s = $this->_oModule->serviceCheckAllowed('No Such Action');
+        $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $s);
+        $this->assertNotEmpty($s);
+    }
+
+    public function testCommentsDisabledShortCircuit()
+    {
+        $aPost = $this->bxSamplePost(['allow_comments' => 0]);
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturn($aPost);
+        $this->bxReplaceDb($oDb);
+
+        $this->assertFalse($this->_oModule->serviceCheckAllowedCommentsPost(15, 'bx_posts'));
+        $this->assertFalse($this->_oModule->serviceCheckAllowedCommentsView(15, 'bx_posts'));
+    }
+
+    public function testCommentsAllowedForNegativeReportId()
+    {
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturn(false);
+        $this->bxReplaceDb($oDb);
+
+        $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->serviceCheckAllowedCommentsPost(-12, 'bx_posts'));
+        $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->serviceCheckAllowedCommentsView(-12, 'bx_posts'));
+    }
+
+    public function testServiceIsActive()
+    {
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturnMap([
+            [1, $this->bxSamplePost()],
+            [2, $this->bxSamplePost(['status' => 'hidden'])],
+            [3, $this->bxSamplePost(['status_admin' => 'pending'])],
+            [4, false],
+        ]);
+        $this->bxReplaceDb($oDb);
+
+        $this->assertTrue($this->_oModule->serviceIsActive(1));
+        $this->assertFalse($this->_oModule->serviceIsActive(2));
+        $this->assertFalse($this->_oModule->serviceIsActive(3));
+        $this->assertFalse($this->_oModule->serviceIsActive(4));
+    }
+
+    public function testServiceCheckAllowedWithContentMissing()
+    {
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturn(false);
+        $this->bxReplaceDb($oDb);
+
+        $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->serviceCheckAllowedWithContent('View', 15));
+    }
+}

+ 146 - 0
tests/units/modules/boonex/posts/classes/BxPostsModuleServicesTest.php

@@ -0,0 +1,146 @@
+<?php
+
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
+
+/**
+ * Content-module services, alerts, and timeline helpers via Posts.
+ */
+#[AllowMockObjectsWithoutExpectations]
+class BxPostsModuleServicesTest extends BxPostsTestCase
+{
+    public function testSafeServicesIncludeTextAndGeneralEndpoints()
+    {
+        $a = $this->_oModule->serviceGetSafeServices();
+
+        foreach (['GetLink', 'Browse', 'EntityCreate', 'BrowsePublic', 'BrowseAuthor', 'GetMenuAddonManageToolsProfileStats'] as $sName)
+            $this->assertArrayHasKey($sName, $a);
+    }
+
+    public function testModuleIconAndSample()
+    {
+        $this->assertSame('file-alt', $this->_oModule->serviceModuleIcon());
+        $this->assertNotEmpty($this->_oModule->serviceModuleSample());
+        $this->assertTrue($this->_oModule->serviceIsAllowedPostInContext());
+    }
+
+    public function testFormsHelper()
+    {
+        $this->assertInstanceOf(BxPostsFormsEntryHelper::class, $this->_oModule->getFormsHelper());
+        $this->assertInstanceOf(BxPostsFormsEntryHelper::class, $this->_oModule->serviceFormsHelper());
+    }
+
+    public function testBuildRssParams()
+    {
+        $this->assertSame(['author' => 7], $this->bxCallProtected($this->_oModule, '_buildRssParams', 'author', [7]));
+        $this->assertSame(['author' => ''], $this->bxCallProtected($this->_oModule, '_buildRssParams', 'author', []));
+        $this->assertSame([], $this->bxCallProtected($this->_oModule, '_buildRssParams', 'public', [1]));
+    }
+
+    public function testPrepareBrowsingFiltersParamsGet()
+    {
+        $this->assertSame(['mode' => 'public'], $this->bxCallProtected(
+            $this->_oModule,
+            '_prepareBrowsingFiltersParamsGet',
+            ['mode' => 'public', 'ignored' => 1]
+        ));
+        $this->assertSame([], $this->bxCallProtected($this->_oModule, '_prepareBrowsingFiltersParamsGet', []));
+    }
+
+    public function testAlertParams()
+    {
+        $aPost = $this->bxSamplePost(['allow_view_to' => 5, 'cf' => 2, 'status' => 'awaiting']);
+        $a = $this->bxCallProtected($this->_oModule, '_alertParams', $aPost);
+
+        $this->assertSame('awaiting', $a['status']);
+        $this->assertSame('active', $a['status_admin']);
+        $this->assertSame(5, $a['privacy_view']);
+        $this->assertSame(2, $a['cf']);
+    }
+
+    public function testAlertParamsAddIncludesTimelineGroup()
+    {
+        $aPost = $this->bxSamplePost(['author' => -10, 'id' => 15]);
+        $a = $this->bxCallProtected($this->_oModule, '_alertParamsAdd', $aPost);
+
+        $this->assertSame('bx_posts_10_15', $a['timeline_group']['by']);
+        $this->assertSame('owner_id', $a['timeline_group']['field']);
+    }
+
+    public function testTimelinePostMissingOrInactive()
+    {
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturnOnConsecutiveCalls(
+            false,
+            $this->bxSamplePost(['status' => 'awaiting']),
+            $this->bxSamplePost(['status_admin' => 'pending'])
+        );
+        $this->bxReplaceDb($oDb);
+
+        $aEvent = ['object_id' => 15, 'owner_id' => 10];
+        $this->assertFalse($this->_oModule->serviceGetTimelinePost($aEvent));
+        $this->assertFalse($this->_oModule->serviceGetTimelinePost($aEvent));
+        $this->assertFalse($this->_oModule->serviceGetTimelinePost($aEvent));
+    }
+
+    public function testTimelinePostHidesAnonymousFromNonAuthor()
+    {
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturn($this->bxSamplePost(['author' => -10]));
+        $this->bxReplaceDb($oDb);
+
+        $this->assertFalse($this->_oModule->serviceGetTimelinePost([
+            'object_id' => 15,
+            'owner_id' => 10,
+        ]));
+    }
+
+    public function testTimelinePostUsesLaterPublishedDate()
+    {
+        $aPost = $this->bxSamplePost([
+            'added' => 100,
+            'published' => 250,
+        ]);
+        $oDb = $this->createMock(BxPostsDb::class);
+        $oDb->method('getContentInfoById')->willReturn($aPost);
+        $this->bxReplaceDb($oDb);
+
+        $aResult = $this->_oModule->serviceGetTimelinePost([
+            'object_id' => 15,
+            'owner_id' => 10,
+        ]);
+        if ($aResult === false)
+            $this->markTestSkipped('Timeline post assembly requires optional content objects.');
+
+        $this->assertIsArray($aResult);
+        $this->assertSame(250, $aResult['date']);
+        $this->assertSame('file-alt', $aResult['icon']);
+    }
+
+    public function testGetContentOwnerProfileIdWithoutContentUsesViewer()
+    {
+        $this->assertSame(bx_get_logged_profile_id(), $this->_oModule->serviceGetContentOwnerProfileId(0));
+    }
+
+    public function testDecodeDataApiPrivateContent()
+    {
+        $o = new class($this->_oModule->_aModule) extends BxPostsModule {
+            public $mixedViewResult = 'denied';
+
+            public function checkAllowedView($aDataEntry, $isPerformAction = false)
+            {
+                return $this->mixedViewResult;
+            }
+        };
+
+        $a = $o->decodeDataAPI($this->bxSamplePost());
+        $this->assertSame(15, $a['id']);
+        $this->assertSame('sys_private', $a['module']);
+        $this->assertSame('bx_posts', $a['module_src']);
+        $this->assertArrayNotHasKey('text', $a);
+    }
+
+    public function testServiceIsAllowedAddContentToContextZero()
+    {
+        $this->assertFalse($this->_oModule->serviceIsAllowedAddContentToContext(0));
+    }
+}

+ 123 - 0
tests/units/modules/boonex/posts/classes/BxPostsSearchResultTest.php

@@ -0,0 +1,123 @@
+<?php
+
+/**
+ * Content-module search/browse modes via Posts SearchResult.
+ */
+class BxPostsSearchResultTest extends BxPostsTestCase
+{
+    public function testPublicMode()
+    {
+        $o = new BxPostsSearchResult('public');
+
+        $this->assertFalse((bool)$o->isError);
+        $this->assertSame('bx_posts', $o->aCurrent['module_name']);
+        $this->assertSame('bx_posts_posts', $o->aCurrent['table']);
+        $this->assertSame('last', $o->aCurrent['sorting']);
+        $this->assertSame('active', $o->aCurrent['restriction']['status']['value']);
+        $this->assertSame('active', $o->aCurrent['restriction']['statusAdmin']['value']);
+        $this->assertSame('modules/?r=posts/rss/public', $o->aCurrent['rss']['link']);
+        $this->assertArrayHasKey('statusAuthor', $o->aCurrent['restriction']);
+    }
+
+    public function testFeaturedPopularTopUpdatedModes()
+    {
+        $oFeatured = new BxPostsSearchResult('featured');
+        $this->assertFalse((bool)$oFeatured->isError);
+        $this->assertSame('featured', $oFeatured->aCurrent['sorting']);
+        $this->assertSame('0', $oFeatured->aCurrent['restriction']['featured']['value']);
+
+        $oPopular = new BxPostsSearchResult('popular');
+        $this->assertSame('popular', $oPopular->aCurrent['sorting']);
+
+        $oTop = new BxPostsSearchResult('top');
+        $this->assertSame('top', $oTop->aCurrent['sorting']);
+
+        $oUpdated = new BxPostsSearchResult('updated');
+        $this->assertSame('updated', $oUpdated->aCurrent['sorting']);
+        $this->assertSame('modules/?r=posts/rss/updated', $oUpdated->aCurrent['rss']['link']);
+    }
+
+    public function testSearchResultsModeDropsPaginationAndRss()
+    {
+        $o = new BxPostsSearchResult('');
+
+        $this->assertFalse((bool)$o->isError);
+        $this->assertArrayNotHasKey('perPage', $o->aCurrent['paginate']);
+        $this->assertArrayNotHasKey('rss', $o->aCurrent);
+    }
+
+    public function testUnknownModeIsError()
+    {
+        $o = new BxPostsSearchResult('not-a-mode');
+        $this->assertTrue((bool)$o->isError);
+    }
+
+    public function testAuthorAndContextWithoutProfileAreErrors()
+    {
+        $oAuthor = new BxPostsSearchResult('author', ['author' => 0]);
+        $this->assertTrue((bool)$oAuthor->isError);
+
+        $oContext = new BxPostsSearchResult('context', []);
+        $this->assertTrue((bool)$oContext->isError);
+
+        $oFollowed = new BxPostsSearchResult('followed_contexts', ['author' => 0]);
+        $this->assertTrue((bool)$oFollowed->isError);
+
+        $oFavorite = new BxPostsSearchResult('favorite', ['user' => 0]);
+        $this->assertTrue((bool)$oFavorite->isError);
+    }
+
+    public function testAlterOrder()
+    {
+        $o = new BxPostsSearchResult('public');
+
+        $o->aCurrent['sorting'] = 'last';
+        $this->assertStringContainsString('`added` DESC', $o->getAlterOrder()['order']);
+
+        $o->aCurrent['sorting'] = 'updated';
+        $this->assertStringContainsString('`changed` DESC', $o->getAlterOrder()['order']);
+
+        $o->aCurrent['sorting'] = 'featured';
+        $this->assertStringContainsString('`featured` DESC', $o->getAlterOrder()['order']);
+
+        $o->aCurrent['sorting'] = 'popular';
+        $this->assertStringContainsString('`views` DESC', $o->getAlterOrder()['order']);
+
+        $o->aCurrent['sorting'] = 'top';
+        $aTop = $o->getAlterOrder();
+        $this->assertArrayHasKey('order', $aTop);
+        $this->assertNotSame('', $aTop['order']);
+    }
+
+    public function testAddConditionsForFilter()
+    {
+        $o = new BxPostsSearchResult('public');
+        $CNF = $this->_oModule->_oConfig->CNF;
+
+        $this->bxCallProtected($o, 'addConditionsForFilter', $CNF, 'public', [
+            'filter' => ['field' => 'cat', 'value' => 4, 'operator' => 'in', 'table' => 'table'],
+        ]);
+
+        $this->assertSame(4, $o->aCurrent['restriction']['filter']['value']);
+        $this->assertSame('cat', $o->aCurrent['restriction']['filter']['field']);
+        $this->assertSame('in', $o->aCurrent['restriction']['filter']['operator']);
+        $this->assertSame('bx_posts_posts', $o->aCurrent['restriction']['filter']['table']);
+    }
+
+    public function testGalleryUnitViewSwitchesTemplate()
+    {
+        $sPrev = bx_get('unit_view');
+        $_GET['unit_view'] = 'gallery';
+        try {
+            $o = new BxPostsSearchResult('public');
+            $oTemplate = new ReflectionProperty($o, 'sUnitTemplate');
+            $oTemplate->setAccessible(true);
+            $this->assertSame('unit_gallery.html', $oTemplate->getValue($o));
+        } finally {
+            if ($sPrev === false)
+                unset($_GET['unit_view']);
+            else
+                $_GET['unit_view'] = $sPrev;
+        }
+    }
+}

+ 67 - 0
tests/units/modules/boonex/posts/classes/BxPostsTemplateTest.php

@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * Content-module template helpers via Posts.
+ */
+class BxPostsTemplateTest extends BxPostsTestCase
+{
+    public function testUnitNameClassAndHtmlId()
+    {
+        $o = $this->bxTemplate();
+        $aPost = $this->bxSamplePost();
+
+        $this->assertSame('unit', $this->bxCallProtected($o, '_getUnitName', $aPost, 'unit.html'));
+        $this->assertSame('unit_gallery', $this->bxCallProtected($o, '_getUnitName', $aPost, 'unit_gallery.html'));
+        $this->assertSame('', $this->bxCallProtected($o, '_getUnitClass', $aPost, 'unit.html'));
+        $this->assertSame(
+            'bx-base-unit-showcase bx-base-text-unit-showcase bx-def-margin-sec-bottom',
+            $this->bxCallProtected($o, '_getUnitClass', $aPost, 'unit_showcase.html')
+        );
+        $this->assertSame('bx-posts-unit-15', $this->bxCallProtected($o, '_getUnitHtmlId', $aPost, 'unit.html'));
+    }
+
+    public function testHeaderImageParams()
+    {
+        $a = $this->bxCallProtected($this->bxTemplate(), '_getHeaderImageParams');
+
+        $this->assertSame('thumb', $a['field']);
+        $this->assertSame('thumb_data', $a['field_position']);
+        $this->assertSame('bx_posts_covers', $a['storage']);
+        $this->assertSame(['bx_posts_html5'], $a['uploaders']);
+        $this->assertSame('bx_posts_cover', $a['transcoder_cover']);
+    }
+
+    public function testCheckPrivacySkippedReturnsEmpty()
+    {
+        $s = $this->bxCallProtected(
+            $this->bxTemplate(),
+            'checkPrivacy',
+            $this->bxSamplePost(),
+            false,
+            $this->_oModule
+        );
+        $this->assertSame('', $s);
+    }
+
+    public function testMediaExifEmpty()
+    {
+        $this->assertSame('', $this->_oModule->_oTemplate->mediaExif([]));
+        $this->assertSame('', $this->_oModule->_oTemplate->mediaExif(['exif' => serialize(['Model' => 'X'])]));
+    }
+
+    public function testGetUnitThumbAndGalleryWithoutThumb()
+    {
+        [$sThumb, $sGallery] = $this->bxCallProtected(
+            $this->bxTemplate(),
+            'getUnitThumbAndGallery',
+            $this->bxSamplePost(['thumb' => 0])
+        );
+        $this->assertSame('', $sThumb);
+        $this->assertSame('', $sGallery);
+    }
+
+    public function testAuthorAddonEmpty()
+    {
+        $this->assertSame('', $this->_oModule->_oTemplate->getAuthorAddon($this->bxSamplePost(), null));
+    }
+}