diff options
Diffstat (limited to 'tests/unit')
28 files changed, 2199 insertions, 41 deletions
diff --git a/tests/unit/CleanupBBCodeTest.php b/tests/unit/CleanupBBCodeTest.php new file mode 100644 index 000000000..8e19b1d7e --- /dev/null +++ b/tests/unit/CleanupBBCodeTest.php @@ -0,0 +1,27 @@ +<?php +/* + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit; + +use PHPUnit\Framework\Attributes\DataProvider; + +class CleanupBBCodeTest extends UnitTestCase { + #[DataProvider("cleanup_bbcode_provider")] + public function test_cleanup_bbcode(string $expected, string $input): void { + $this->assertEquals($expected, cleanup_bbcode($input)); + } + + public static function cleanup_bbcode_provider(): array { + return [ + 'url followed by newline' => [ + "#^[url=https://example.com]https://example.com[/url]\na test link", + "https://example.com\na test link", + ] + ]; + } +} diff --git a/tests/unit/Lib/ActivityStreamsTest.php b/tests/unit/Lib/ActivityStreamsTest.php new file mode 100644 index 000000000..38be1792e --- /dev/null +++ b/tests/unit/Lib/ActivityStreamsTest.php @@ -0,0 +1,136 @@ +<?php +/** + * Unit tests for Zotlabs\Lib\ActivityStreams. + * + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Lib; + +use phpmock\phpunit\PHPMock; +use Zotlabs\Lib\ActivityStreams; +use Zotlabs\Tests\Unit\UnitTestCase; + +class ActivityStreamsTest extends UnitTestCase { + + // Import PHPMock methods into this class + use PHPMock; + + /** + * Test parsing an announce activity of a like from a remote server of + * a note from a third server. + * + * Also test that we fetch the referenced objects when the received + * activity is parsed, + */ + public function test_parse_announce_of_like(): void { + $payload = <<<JSON + { + "actor": "https://lemmy.test/c/technology", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://lemmy.test/activities/like/e6e38c8b-beee-406f-9523-9da7ec97a823", + "actor": "https://lemmy.test/u/SomePerson", + "object": "https://somesite.test/post/1197552", + "type": "Like", + "audience": "https://lemmy.test/c/technology" + }, + "cc": [ + "https://lemmy.test/c/technology/followers" + ], + "type": "Announce", + "id": "https://lemmy.test/activities/announce/like/9e583a54-e4e0-4436-9726-975a14f923ed" + } + JSON; + + // + // Mock z_fetch_url to prevent us from spamming real servers during test runs + // + // We just create some sample ActivityStreams objects to return for the various + // URL's to make it a somewhat realistic test. Each object will have it's URL as + // it's id and only specify the object type as laid out in the $urlmap below. + + $urlmap = [ + 'https://lemmy.test/c/technology' => [ 'type' => 'Group' ], + 'https://lemmy.test/u/SomePerson' => [ 'type' => 'Person' ], + 'https://somesite.test/post/1197552' => [ 'type' => 'Note' ], + ]; + + $z_fetch_url_stub = $this->getFunctionMock('Zotlabs\Lib', 'z_fetch_url'); + $z_fetch_url_stub + ->expects($this->any()) + ->willReturnCallback(function ($url) use ($urlmap) { + if (isset($urlmap[$url])) { + $body = json_encode( + array_merge([ 'id' => $url ], $urlmap[$url]), + JSON_FORCE_OBJECT, + ); + + return [ + 'success' => true, + 'body' => $body, + ]; + } else { + // We should perhaps throw an error here to fail the test, + // as we're receiving an unexpected URL. + return [ + 'success' => false, + ]; + } + }); + + // Make sure we have a sys channel before we start + create_sys_channel(); + + $as = new ActivityStreams($payload); + + $this->assertTrue($as->valid); + + $this->assertEquals( + 'https://lemmy.test/activities/announce/like/9e583a54-e4e0-4436-9726-975a14f923ed', + $as->id + ); + + $this->assertEquals('Announce', $as->type); + + $this->assertIsArray($as->actor); + $this->assertArrayHasKey('id', $as->actor); + $this->assertEquals('https://lemmy.test/c/technology', $as->actor['id']); + $this->assertArrayHasKey('type', $as->actor); + $this->assertEquals('Group', $as->actor['type']); + + $this->assertIsArray($as->recips); + $this->assertContains('https://www.w3.org/ns/activitystreams#Public', $as->recips); + $this->assertContains('https://lemmy.test/c/technology/followers', $as->recips); + $this->assertContains('https://lemmy.test/c/technology', $as->recips); + + $this->assertIsArray($as->obj); + $this->assertArrayHasKey('id', $as->obj); + $this->assertEquals( + 'https://lemmy.test/activities/like/e6e38c8b-beee-406f-9523-9da7ec97a823', + $as->obj['id'] + ); + $this->assertArrayHasKey('type', $as->obj); + $this->assertEquals('Like', $as->obj['type']); + $this->assertArrayHasKey('object', $as->obj); + + $this->assertIsArray($as->obj['object']); + + $this->assertArrayHasKey('id', $as->obj['object']); + $this->assertEquals('https://somesite.test/post/1197552', $as->obj['object']['id']); + + $this->assertArrayHasKey('type', $as->obj['object']); + $this->assertEquals('Note', $as->obj['object']['type']); + + $this->assertIsArray($as->obj['actor']); + $this->assertArrayHasKey('id', $as->obj['actor']); + $this->assertEquals('https://lemmy.test/u/SomePerson', $as->obj['actor']['id']); + $this->assertArrayHasKey('type', $as->obj['actor']); + $this->assertEquals('Person', $as->obj['actor']['type']); + } +} diff --git a/tests/unit/Lib/ActivityTest.php b/tests/unit/Lib/ActivityTest.php index 0e2703f2b..46f53ecd9 100644 --- a/tests/unit/Lib/ActivityTest.php +++ b/tests/unit/Lib/ActivityTest.php @@ -5,8 +5,13 @@ error_reporting(E_ALL); use Zotlabs\Tests\Unit\UnitTestCase; use Zotlabs\Lib\Activity; +use Zotlabs\Lib\ActivityStreams; +use phpmock\phpunit\PHPMock; class ActivityTest extends UnitTestCase { + // Import PHPMock methods into this class + use PHPMock; + /** * Test get a textfield from an activitystreams object * @@ -36,4 +41,244 @@ class ActivityTest extends UnitTestCase { ]; } + + /** + * + * @dataProvider get_mid_and_uuid_provider + */ + public function test_get_mid_and_uuid(string $payload, string $mid, string $uuid): void { + + + // + // Mock z_fetch_url to prevent us from spamming real servers during test runs + // + // We just create some sample ActivityStreams objects to return for the various + // URL's to make it a somewhat realistic test. Each object will have it's URL as + // it's id and only specify the object type as laid out in the $urlmap below. + + $item_json = '{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1","https://purl.archive.org/socialweb/webfinger",{"zot":"https://hub.somaton.com/apschema#","contextHistory":"https://w3id.org/fep/171b/contextHistory","schema":"http://schema.org#","ostatus":"http://ostatus.org#","diaspora":"https://diasporafoundation.org/ns/","litepub":"http://litepub.social/ns#","toot":"http://joinmastodon.org/ns#","commentPolicy":"zot:commentPolicy","Bookmark":"zot:Bookmark","Category":"zot:Category","Emoji":"toot:Emoji","directMessage":"litepub:directMessage","PropertyValue":"schema:PropertyValue","value":"schema:value","uuid":"schema:identifier","conversation":"ostatus:conversation","guid":"diaspora:guid","manuallyApprovesFollowers":"as:manuallyApprovesFollowers","Hashtag":"as:Hashtag"}],"type":"Note","id":"https://hub.somaton.com/item/7bb5da01-f97b-408f-853a-eb4d09079e5a","diaspora:guid":"7bb5da01-f97b-408f-853a-eb4d09079e5a","published":"2025-04-14T05:43:00Z","attributedTo":"https://hub.somaton.com/channel/mario","inReplyTo":"https://social.wake.st/users/liaizon/statuses/114332208953644534","context":"https://social.wake.st/users/liaizon/statuses/114332208953644534","content":"@<a href=\"https://social.wake.st/users/liaizon\" target=\"_blank\" rel=\"nofollow noopener\" >wakest \u2042</a>,<br />@<a class=\"zrl\" href=\"https://hubzilla.org/channel/info\" target=\"_blank\" rel=\"nofollow noopener\" >Hubzilla</a> has had <span style=\"color: magenta;\">colors</span> <span style=\"color: lightgreen;\">since</span> <span style=\"color: blue;\">10+</span> <span style=\"color: orange;\">years</span> :grinning_face_with_smiling_eyes:","source":{"content":"@[url=https://social.wake.st/users/liaizon]wakest \u2042[/url],\r\n@[zrl=https://hubzilla.org/channel/info]Hubzilla[/zrl] has had [color=magenta]colors[/color] [color=lightgreen]since[/color] [color=blue]10+[/color] [color=orange]years[/color] :grinning_face_with_smiling_eyes:","mediaType":"text/bbcode"},"actor":"https://hub.somaton.com/channel/mario","tag":[{"type":"Mention","href":"https://social.wake.st/users/liaizon","name":"@wakest \u2042"},{"type":"Mention","href":"https://hubzilla.org/channel/info","name":"@Hubzilla"},{"type":"Emoji","id":"https://hub.somaton.com/emoji/grinning_face_with_smiling_eyes","name":":grinning_face_with_smiling_eyes:","icon":{"type":"Image","url":"https://hub.somaton.com/addon/emoji/emojitwo/1f604.png"}}],"to":["https://www.w3.org/ns/activitystreams#Public","https://social.wake.st/users/liaizon","https://hubzilla.org/channel/info"],"cc":["https://social.wake.st/users/liaizon/followers","https://app.wafrn.net/fediverse/blog/wafrn"],"proof":{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","created":"2025-04-14T15:18:46Z","verificationMethod":"https://hub.somaton.com/channel/mario#z6MkfHv7DiVBDs7qZJVfbLUHLbKFYBxdhDBeqHRmhpWq8Pj9","proofPurpose":"assertionMethod","proofValue":"z5bxnZPhccvxtKZAMPxPexeqHgfzmL6U9jX6mSDbUCi3xmtLgsDbjMtAvMS2f8qw9rHBsFHyo2999xWmfPDZsCZ8U"},"signature":{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],"type":"RsaSignature2017","nonce":"2fd1920c5b8906ce4d3bfbdd9ff967c10e5e37202e227fb40dc0a2a14e887093","creator":"https://hub.somaton.com/channel/mario","created":"2025-04-14T15:18:46Z","signatureValue":"Xlg0UISgRoeTfB2Qpy/UqZ4jQeNeGUVx0Gztp7uhqtj8lbQNmWyZqUzXdXf7jGjiVcy87rmdakSfcQG9Zvbak/illLePj8pkXixLWdquoyJ5v/MhDtfgEoKikGSP3mkunabNNL1yFm5uZ6dYjS4ea0lB/1YPIyWjP7NhLbv0+HD/02a9P87Nlwufh1PUFoL9Y4XPIJpparz5Wax5fIfqzmVSMa0QLN+d/zQb+/jdOszhdiTZdUgpRK4Yb8xKeRBO1kOngtOfD0I7szUdRlTmFIpi83HKRNTAJrGyFsCwTnZmIy0dHhxmyvarHyz2kuEcf8nz3z5BV8amo7edAx9wWizsRiYaiMQ65mgl6wfZapHzkUqGH7mT9Kp3YmTOTgCy9OyP7yXyUUx5MRnyqQnjzoEw6stQwNb+IuhfwRcoLwJ/sIY5db29FK3QrbMCKNvxxJUjBqq+rdUjXnpvpdm9i8X1oJ1dHtkqSNoOBleykNudxyDRjvy+uI9z6OLt3LyNorOQ+3RUxxSxONoAsb+DVuLldMfD8ASVZWMzPr2CnyAuf8EFHccCoHibiNbMRuovk+kcLd+47B+v/tOq+rk6bPQ+np323nyUYZDGrH7KYgkQuXA83E2bLd3pOFfVQjDGEJlwrSx3U7wj+DDQohN1DqIkoK7WBpU1cFI6nn0r6ak="}}'; + + $urlmap = [ + 'https://hub.somaton.com/channel/mario' => [ 'type' => 'Person' ], + 'https://mitra.social/users/silverpill' => [ 'type' => 'Person' ], + 'https://hub.somaton.com/item/7bb5da01-f97b-408f-853a-eb4d09079e5a' => json_decode($item_json, true) + ]; + + $z_fetch_url_stub = $this->getFunctionMock('Zotlabs\Lib', 'z_fetch_url'); + $z_fetch_url_stub + ->expects($this->any()) + ->willReturnCallback(function ($url) use ($urlmap) { + if (isset($urlmap[$url])) { + $body = json_encode( + array_merge([ 'id' => $url ], $urlmap[$url]), + JSON_FORCE_OBJECT, + ); + + return [ + 'success' => true, + 'body' => $body, + ]; + } else { + // We should perhaps throw an error here to fail the test, + // as we're receiving an unexpected URL. + return [ + 'success' => false, + ]; + } + }); + + // Make sure we have a sys channel before we start + create_sys_channel(); + + $as = new ActivityStreams($payload); + + $this->assertEquals($mid, Activity::getMessageID($as)); + $this->assertEquals($uuid, Activity::getUUID($as)); + } + + /** + * Dataprovider for test_get_mid_and_uuid. + */ + public static function get_mid_and_uuid_provider(): array { + return [ + 'Note from hubzilla with diaspora:guid' => [ + '{ + "@context":[ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://purl.archive.org/socialweb/webfinger", + { + "zot":"https://hub.somaton.com/apschema#", + "contextHistory":"https://w3id.org/fep/171b/contextHistory", + "schema":"http://schema.org#", + "ostatus":"http://ostatus.org#", + "diaspora":"https://diasporafoundation.org/ns/", + "litepub":"http://litepub.social/ns#", + "toot":"http://joinmastodon.org/ns#", + "commentPolicy":"zot:commentPolicy", + "Bookmark":"zot:Bookmark", + "Category":"zot:Category", + "Emoji":"toot:Emoji", + "directMessage":"litepub:directMessage", + "PropertyValue":"schema:PropertyValue", + "value":"schema:value", + "uuid":"schema:identifier", + "conversation":"ostatus:conversation", + "guid":"diaspora:guid", + "manuallyApprovesFollowers":"as:manuallyApprovesFollowers", + "Hashtag":"as:Hashtag" + } + ], + "type":"Note", + "id":"https://hub.somaton.com/item/2e4b2cfa-7c20-49c2-b192-ae54f43a211a", + "diaspora:guid":"2e4b2cfa-7c20-49c2-b192-ae54f43a211a", + "published":"2025-04-03T17:45:41Z", + "commentPolicy":"contacts", + "attributedTo":"https://hub.somaton.com/channel/mario", + "contextHistory":"https://hub.somaton.com/conversation/2e4b2cfa-7c20-49c2-b192-ae54f43a211a", + "context":"https://hub.somaton.com/conversation/2e4b2cfa-7c20-49c2-b192-ae54f43a211a", + "content":"Looks like we have a :hubzilla: emoji now :slightly_smiling_face:", + "source":{ + "content":"Looks like we have a :hubzilla: emoji now :slightly_smiling_face:", + "mediaType":"text/bbcode" + }, + "actor":"https://hub.somaton.com/channel/mario", + "tag":[ + { + "type":"Emoji", + "id":"https://hub.somaton.com/emoji/hubzilla", + "name":":hubzilla:", + "icon":{ + "type":"Image", + "url":"https://hub.somaton.com/images/hubzilla.svg" + } + }, + { + "type":"Emoji", + "id":"https://hub.somaton.com/emoji/slightly_smiling_face", + "name":":slightly_smiling_face:", + "icon":{ + "type":"Image", + "url":"https://hub.somaton.com/images/emoji/slightly_smiling_face.png" + } + } + ], + "to":[ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc":[ + "https://hub.somaton.com/followers/mario" + ], + "proof":{ + "type":"DataIntegrityProof", + "cryptosuite":"eddsa-jcs-2022", + "created":"2025-04-14T15:25:17Z", + "verificationMethod":"https://hub.somaton.com/channel/mario#z6MkfHv7DiVBDs7qZJVfbLUHLbKFYBxdhDBeqHRmhpWq8Pj9", + "proofPurpose":"assertionMethod", + "proofValue":"zJf7xXBtD6ZTG27171X7X1BSkw7kijw4MCvzhowL7giB5s3mUKbo9yF1wq29E3bvHc3Q7HbDzUdbFE8cpCYYH9uJ" + }, + "signature":{ + "@context":[ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "type":"RsaSignature2017", + "nonce":"3cda8c90fdbe708d625a017d1c946c8144fa288c9a04124b40b27f2f6a429e94", + "creator":"https://hub.somaton.com/channel/mario", + "created":"2025-04-14T15:25:17Z", + "signatureValue":"d01N0pMca7I9/dCdYbwuY3/SUe0xCZfwRSPxA7w9Pj4fFYDwhCNVYLKWz66K7RP7KfDD7DQ3oS8wLxn4qSX7wjFDJhwy7PkbGUzawBc9eti+8wHbiMGD2JgZCbzGaXmR/k5zyOykKhqglUOr0BcvAfqM1g3+7UxtYdxMNFlJ9nAJTObmd5jR8RyPe9b7Tbgi5XJDJ4U4qLsb8tAK54Sr2208fuCs7T+baErMPNj4eVprWoJObnr6sQX4YJH3404eJpExMLSu+y9taWLXxg6qDv+EY/RjgbKh/cdYNB5ERDFVK4WxgrrJCTv5t7mdVxjDN3sHUsfeT1aF2JYbS5ISSdtdHZnEMNIw3uwXLm5zG76fk17nUdDfXm1Pyu2uAuwRYIBOMQWeFZvdvo1Lf457kCQQN0DgUv3t89JD7Y5fZAzOlKiqXb52cxlsNUQFw8vQnWLGZNdqpDU0np6IhABrsMo+QoYrQepwKzxnmy8cwB6KKyD8W75H49l79DslDvg71nue3MuLtIfaI+d1GhYIul9o0ttJnzTbvg6L+pLLtzwgsDCqVhkXgQmk7J8RUuux9gmqYMe0pCoDlrVcR0Jhte57JqgqYZck3BPupLuu+Pg4n5/SIAogsCCWOu4ygV7jwAOcPmze7XozBuP7CVFVpgfooo9rU3kPKeEETkcljKg=" + } + }', + 'https://hub.somaton.com/item/2e4b2cfa-7c20-49c2-b192-ae54f43a211a', + '2e4b2cfa-7c20-49c2-b192-ae54f43a211a' + ], + + 'Like(Note) from mitra without uuid' => [ + '{ + "@context":[ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://w3id.org/security/data-integrity/v1", + { + "Emoji":"toot:Emoji", + "EmojiReact":"litepub:EmojiReact", + "Hashtag":"as:Hashtag", + "litepub":"http://litepub.social/ns#", + "sensitive":"as:sensitive", + "toot":"http://joinmastodon.org/ns#" + } + ], + "actor":"https://mitra.social/users/silverpill", + "cc":[ + + ], + "id":"https://mitra.social/activities/like/01963430-e998-4bef-c903-50903dde06dc", + "object":"https://hub.somaton.com/item/7bb5da01-f97b-408f-853a-eb4d09079e5a", + "proof":{ + "created":"2025-04-14T12:05:42.945286724Z", + "cryptosuite":"eddsa-jcs-2022", + "proofPurpose":"assertionMethod", + "proofValue":"z4XrZRkUBoxmAn1xYXwmhaJTXb9Mog9C3cjrPenRhqWQbfXv6QJmMyGydsQ3LqN61uVfRvis8RoTymyUPqy76k9mg", + "type":"DataIntegrityProof", + "verificationMethod":"https://mitra.social/users/silverpill#ed25519-key" + }, + "to":[ + "https://hub.somaton.com/channel/mario", + "https://www.w3.org/ns/activitystreams#Public" + ], + "type":"Like" + }', + 'https://mitra.social/activities/like/01963430-e998-4bef-c903-50903dde06dc', + '' + ], + + 'Like(Note) from mitra with manually added uuid' => [ + '{ + "@context":[ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://w3id.org/security/data-integrity/v1", + { + "Emoji":"toot:Emoji", + "EmojiReact":"litepub:EmojiReact", + "Hashtag":"as:Hashtag", + "litepub":"http://litepub.social/ns#", + "sensitive":"as:sensitive", + "toot":"http://joinmastodon.org/ns#" + } + ], + "actor":"https://mitra.social/users/silverpill", + "cc":[ + + ], + "id":"https://mitra.social/activities/like/01963430-e998-4bef-c903-50903dde06dc", + "uuid":"01963430-e998-4bef-c903-50903dde06dc", + "object":"https://hub.somaton.com/item/7bb5da01-f97b-408f-853a-eb4d09079e5a", + "proof":{ + "created":"2025-04-14T12:05:42.945286724Z", + "cryptosuite":"eddsa-jcs-2022", + "proofPurpose":"assertionMethod", + "proofValue":"z4XrZRkUBoxmAn1xYXwmhaJTXb9Mog9C3cjrPenRhqWQbfXv6QJmMyGydsQ3LqN61uVfRvis8RoTymyUPqy76k9mg", + "type":"DataIntegrityProof", + "verificationMethod":"https://mitra.social/users/silverpill#ed25519-key" + }, + "to":[ + "https://hub.somaton.com/channel/mario", + "https://www.w3.org/ns/activitystreams#Public" + ], + "type":"Like" + }', + 'https://mitra.social/activities/like/01963430-e998-4bef-c903-50903dde06dc', + '01963430-e998-4bef-c903-50903dde06dc' + ] + ]; + } + + public function testBuildPacketWithEmptyChannel(): void { + $data = [ 'aKey' => 'aValue' ]; + $packet = json_decode(Activity::build_packet($data, []), true); + + $this->assertArrayHasKey('aKey', $packet); + $this->assertEquals('aValue', $packet['aKey']); + } } diff --git a/tests/unit/Lib/JcsEddsa2022Test.php b/tests/unit/Lib/JcsEddsa2022Test.php index d18ad01ce..7cdc655f8 100644 --- a/tests/unit/Lib/JcsEddsa2022Test.php +++ b/tests/unit/Lib/JcsEddsa2022Test.php @@ -3,6 +3,7 @@ namespace Zotlabs\Tests\Unit\Lib; use Zotlabs\Lib\JcsEddsa2022; +use Zotlabs\Lib\JcsEddsa2022SignException; use Zotlabs\Tests\Unit\UnitTestCase; class JcsEddsa2022Test extends UnitTestCase { @@ -171,4 +172,11 @@ class JcsEddsa2022Test extends UnitTestCase { $this->assertTrue($verified, 'Verify encode and decode eddsa-jcs-2022'); } + + public function testSignWithInvalidChannelShouldBeRejected(): void { + $this->expectException(JcsEddsa2022SignException::class); + + $alg = new JcsEddsa2022(); + $res = $alg->sign([], []); + } } diff --git a/tests/unit/Lib/MailerTest.php b/tests/unit/Lib/MailerTest.php new file mode 100644 index 000000000..038c7ef4c --- /dev/null +++ b/tests/unit/Lib/MailerTest.php @@ -0,0 +1,62 @@ +<?php +/* + * Tests for the Zotlabs\LibM̀ailer class. + * + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Lib; + +use App; +use phpmock\phpunit\PHPMock; +use Zotlabs\Lib\Mailer; +use Zotlabs\Tests\Unit\UnitTestCase; + +class MailerTest extends UnitTestCase { + + use PHPMock; + + public function test_optional_params_replaced_by_defaults(): void { + $hostname = App::get_hostname(); + $recipient = 'recipient@somesite.test'; + $subject = 'A test email'; + $body = <<<EOF + Dear recipient, + + This is an test email message for you. + + Sincerely, + Hubzilla + EOF; + + // + // Catch calls to the php mail function, and verify + // that it is called with the args we're expecting + // + $this->getFunctionMock('Zotlabs\Lib', 'mail') + ->expects($this->once()) + ->with( + $this->identicalTo($recipient), + $this->identicalTo($subject), + $this->identicalTo($body), + $this->identicalTo(<<<EOF + From: <Administrator@{$hostname}> + Reply-To: <noreply@{$hostname}> + Content-Type: text/plain; charset=UTF-8 + EOF + ) + ) + ->willReturn(true); + + $mailer = new Mailer([ + 'toEmail' => $recipient, + 'messageSubject' => $subject, + 'textVersion' => $body, + ]); + + $mailer->deliver(); + } +} diff --git a/tests/unit/Lib/MessageFilterTest.php b/tests/unit/Lib/MessageFilterTest.php new file mode 100644 index 000000000..0a2aea0c6 --- /dev/null +++ b/tests/unit/Lib/MessageFilterTest.php @@ -0,0 +1,207 @@ +<?php +namespace Zotlabs\Tests\Unit\Lib; + +use Zotlabs\Tests\Unit\UnitTestCase; +use Zotlabs\Lib\MessageFilter; + +class MessageFilterTest extends UnitTestCase { + + /** + * Test the `evaluate` function. + * + * @dataProvider evaluate_provider + */ + public function test_evaluate(string $incl, string $excl, bool $result) : void { + // This is for simpler handling of the timestamps + date_default_timezone_set('UTC'); + + $item = [ + 'title' => '', + 'body' => "A grasshopper spent the summer hopping about in the sun and singing to his heart's content. One day, an ant went hurrying by, looking very hot and weary.\r\n#story #grasshopper #ant", + 'term' => [ + ['ttype' => TERM_HASHTAG, 'term' => 'story'], + ['ttype' => TERM_HASHTAG, 'term' => 'grasshopper'], + ['ttype' => TERM_HASHTAG, 'term' => 'ant'] + ], + 'verb' => 'Create', + 'obj_type' => 'Note', + 'obj' => [ + 'type' => 'Note', + 'attributedTo' => 'https://example.com/users/test', + 'summary' => null, + 'content' => "A grasshopper spent the summer hopping about in the sun and singing to his heart's content. One day, an ant went hurrying by, looking very hot and weary.\r\n#story #grasshopper #ant", + 'sensitive' => false + ], + 'item_private' => 0, + 'item_thread_top' => 1, + 'created' => '2025-04-18 20:50:00' + ]; + + $this->assertEquals($result, MessageFilter::evaluate($item, $incl, $excl)); + } + + public static function evaluate_provider() : array { + return [ + 'body contains incl' => [ + 'summer', + '', + true + ], + 'body contains excl' => [ + '', + 'summer', + false + ], + 'body contains word hopper (starting with a space) in excl using regex' => [ + '', + '/ hopper/', + true + ], + 'lang=en in incl' => [ + 'lang=en', + '', + true + ], + 'lang=en in excl' => [ + '', + 'lang=en', + false + ], + 'lang=de in incl' => [ + 'lang=de', + '', + false + ], + 'lang=de in excl' => [ + '', + 'lang=de', + true + ], + 'until=2025-04-18 20:49:00 in excl' => [ + '', + 'until=2025-04-18 20:49:00', + true + ], + 'until=2025-04-18 20:51:00 in excl' => [ + '', + 'until=2025-04-18 20:51:00', + false + ], + 'until=2025-04-18 20:49:00 in incl' => [ + 'until=2025-04-18 20:49:00', + '', + false + ], + 'until=2025-04-18 20:51:00 in incl' => [ + 'until=2025-04-18 20:51:00', + '', + true + ], + 'hashtag in incl' => [ + '#grasshopper', + '', + true + ], + 'hashtag in excl' => [ + '', + '#grasshopper', + false + ], + 'any hashtag in excl' => [ + '', + '#*', + false + ], + 'item.body contains substring hopper in excl' => [ + '', + '?body ~= hopper', + false + ], + 'item.verb == Announce in excl' => [ + '', + '?verb == Announce', + true + ], + 'item.verb != Announce in incl' => [ + '?verb != Announce', + '', + true + ], + 'combined body contains word and item.verb == Announce in excl' => [ + '', + "summer\r\n?verb == Announce", + false + ], + 'item.item_thread_top == 1 in excl' => [ + '', + "?item_thread_top == 1", + false + ], + 'combined item_private == 0 and item.item_thread_top == 1 in excl' => [ + '', + "?item_private == 0\r\n?item_thread_top == 1", + false + ], + 'item.item_private < 1 in excl' => [ + '', + "?item_private < 1", + false + ], + 'item.item_thread_top = 1 and item.item_private > 0 in excl' => [ + '', + "?item_thread_top == 1 && ?item_private > 0 ", + true + ], + 'item.item_thread_top = 1 and item.item_private < 1 in excl' => [ + '', + "?item_thread_top == 1 && ?item_private < 1 ", + false + ], + 'item.item_thread_top = 1 or item.item_private = 0 in excl' => [ + '', + "?item_thread_top == 1 && ?item_private == 0", + false + ], + 'item.item_private < 1 and item.item_thread_top = 1 in excl' => [ + '', + "?item_private < 1 && ?item_thread_top == 1", + false + ], + 'item.item_private < 1 and item.item_thread_top = 0 in excl' => [ + '', + "?item_private < 1 && ?item_thread_top == 0", + true + ], + 'combined item.verb = Create, item.item_private < 1 and item.item_thread_top = 0 in excl' => [ + '', + "?verb == Create\r\n?item_private < 1 && ?item_thread_top == 1", + false + ], + 'item.obj contains value Note in incl' => [ + '?obj {} Note', + '', + true + ], + 'item.obj contains key type in incl' => [ + '?obj {*} type', + '', + true + ], + 'obj.type = Note in incl' => [ + '?+type == Note', + '', + true + ], + 'obj.sensitive = true in incl' => [ + '?+sensitive', + '', + false + ], + 'obj.sensitive != false in incl' => [ + '?+!sensitive', + '', + true + ], + ]; + } +} diff --git a/tests/unit/Module/AdminAccountEditTest.php b/tests/unit/Module/AdminAccountEditTest.php new file mode 100644 index 000000000..818f30f26 --- /dev/null +++ b/tests/unit/Module/AdminAccountEditTest.php @@ -0,0 +1,222 @@ +<?php +/* Tests for Account_edit module + * + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Module; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\{Before, After}; + +class AdminAccountEditTest extends TestCase { + + private $stub_is_site_admin; + private $stub_info; + private $stub_notice; + private $stub_check_security; + private $stub_get_form_security_token; + + private array $info; + private array $notice; + + #[Before] + public function setup_mocks(): void { + /* + * As we're testing pages that should only be reachable by the + * site admin, it makes no sense to have it return anything else + * than true. + */ + $this->stub_is_site_admin = + $this->getFunctionMock('Zotlabs\Module', 'is_site_admin') + ->expects($this->once()) + ->willReturn(true); + + $this->info = []; + $this->stub_info = + $this->getFunctionMock('Zotlabs\Module\Admin', 'info') + ->expects($this->any()) + ->willReturnCallback(function (string $arg) { + $this->info[] = $arg; + }); + + $this->notice = []; + $this->stub_notice = + $this->getFunctionMock('Zotlabs\Module\Admin', 'notice') + ->expects($this->any()) + ->willReturnCallback(function (string $arg) { + $this->notice[] = $arg; + }); + + } + + #[After] + public function tear_down_mocks(): void { + $this->stub_is_site_admin = null; + $this->stub_info = null; + $this->stub_notice = null; + $this->stub_check_security = null; + $this->stub_get_form_security_token = null; + } + + public function test_rendering_admin_account_edit_page(): void { + $this->stub_get_form_security_token = + $this->getFunctionMock('Zotlabs\Module\Admin', 'get_form_security_token') + ->expects($this->once()) + ->willReturn('the-csrf-token'); + + $account = $this->fixtures['account'][0]; + + $this->get("admin/account_edit/{$account['account_id']}"); + + $this->assertPageContains("<form action=\"admin/account_edit/{$account['account_id']}\" method=\"post\""); + $this->assertPageContains($account['account_email']); + + // Check that we generate a CSRF token for the form + $this->assertPageContains("<input type=\"hidden\" name=\"security\" value=\"the-csrf-token\""); + } + + public function test_rendering_admin_account_edit_page_fails_if_id_is_not_found(): void { + $this->get("admin/account_edit/666"); + + $this->assertEquals('', \App::$page['content']); + } + + public function test_rendering_admin_account_edit_page_fails_if_id_is_not_numeric(): void { + $this->get("admin/account_edit/66invalid"); + + $this->assertEquals('', \App::$page['content']); + } + + public function test_post_empty_form_does_not_modify_account(): void { + $this->stub_goaway(); + $this->stub_check_form_security(true); + + $account = get_account_by_id($this->fixtures['account'][0]['account_id']); + + try { + $this->post( + "admin/account_edit/{$account['account_id']}", + [], + [ + 'aid' => $account['account_id'], + 'pass1' => '', + 'pass2' => '', + 'service_class' => $account['account_service_class'], + 'account_language' => $account['account_language'], + 'security' => 'The security token', + ] + ); + } catch (RedirectException $ex) { + $this->assertEquals(z_root() . '/admin/accounts', $ex->getMessage()); + } + + $reloaded = get_account_by_id($account['account_id']); + + $this->assertEquals($account, $reloaded); + + // Not sure if this is expected behaviour, but this is how it is today. + $this->assertContains('Account settings updated.' . EOL, $this->info); + } + + public function test_post_form_changes_account(): void { + $this->stub_goaway(); + $this->stub_check_form_security(true); + + // clone account from fixture, to ensure it's not replaced with + // the reloaded one below. + $account = get_account_by_id($this->fixtures['account'][0]['account_id']); + + try { + $this->post( + "admin/account_edit/{$account['account_id']}", + [], + [ + 'aid' => $account['account_id'], + 'pass1' => 'hunter2', + 'pass2' => 'hunter2', + 'service_class' => 'Some other class', + 'account_language' => 'nn', + 'security' => 'The security token', + ] + ); + } catch (RedirectException $ex) { + $this->assertEquals(z_root() . '/admin/accounts', $ex->getMessage()); + } + + $reloaded = get_account_by_id($account['account_id']); + + $this->assertNotEquals($account, $reloaded); + $this->assertEquals('Some other class', $reloaded['account_service_class']); + $this->assertEquals('nn', $reloaded['account_language']); + + $now = new DateTimeImmutable('now'); + $this->assertEquals($now->format('Y-m-d H:i:s'), $reloaded['account_password_changed']); + + $this->assertContains('Account settings updated.' . EOL, $this->info); + $this->assertContains("Password changed for account {$account['account_id']}." . EOL, $this->info); + } + + public function test_form_with_missing_or_incalid_csrf_token_is_rejected(): void { + $this->expectException(KillmeException::class); + + // Emulate a failed CSRF check + $this->stub_check_form_security(false); + + $account_id = $this->fixtures['account'][0]['account_id']; + + $this->post( + "admin/account_edit/{$account_id}", + [], + [ + 'aid' => $account_id, + 'pass1' => 'hunter2', + 'pass2' => 'hunter2', + 'service_class' => 'Some other class', + 'account_language' => 'nn', + 'security' => 'Invalid security token', + ] + ); + } + + /* + * Override the stub_goaway method because we need the stub to live in the + * Admin namespace. + */ + protected function stub_goaway(): void { + $this->goaway_stub = $this->getFunctionMock('Zotlabs\Module\Admin', 'goaway') + ->expects($this->once()) + ->willReturnCallback( + function (string $uri) { + throw new RedirectException($uri); + } + ); + } + + /** + * Stub the check_form_security_token_ForbiddenOnErr. + * + * In these tests we're not really interested in _how_ the form security + * tokens work, but that the code under test perform the checks. This stub + * allows us to do that without having to worry if everything is set up so + * that the real function would work or not. + * + * @param bool $valid true if emulating a valid token, false otherwise. + */ + protected function stub_check_form_security(bool $valid): void { + $this->stub_check_security = + $this->getFunctionMock('Zotlabs\Module\Admin', 'check_form_security_token_ForbiddenOnErr') + ->expects($this->once()) + ->with( + $this->identicalTo('admin_account_edit'), + $this->identicalTo('security')) + ->willReturnCallback(function () use ($valid) { + if (! $valid) { + throw new KillmeException(); + } + }); + } +} diff --git a/tests/unit/Module/AdminAccountsTest.php b/tests/unit/Module/AdminAccountsTest.php new file mode 100644 index 000000000..2c76f2779 --- /dev/null +++ b/tests/unit/Module/AdminAccountsTest.php @@ -0,0 +1,173 @@ +<?php +/* + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Module; + +use PHPUnit\Framework\Attributes\Before; + +class AdminAccountsTest extends TestCase { + + protected $stub_check_security; + protected $stub_is_site_admin; + protected $stub_goaway; + protected $stub_notice; + + protected array $notice; + + /** + * Set up the stubs common for the tests. + */ + #[Before] + public function setup_stubs(): void { + $this->stub_check_form_security(); + $this->stub_is_site_admin(); + $this->stub_goaway(); + $this->stub_notice(); + } + + public function test_blocking_accounts_marks_selected_accounts_as_blocked(): void { + $params = [ + 'user' => [ 42 ], + 'blocked' => [ false ], + 'page_accounts_block' => true, + ]; + + try { + $this->post('admin/accounts', [], $params); + } catch (RedirectException $redirect) { + $this->assertEquals(z_root() . '/admin/accounts', $redirect->getMessage()); + } + + $account = get_account_by_id(42); + $this->assertEquals(ACCOUNT_BLOCKED, $account['account_flags'] & ACCOUNT_BLOCKED); + + $this->assertEquals('1 account blocked/unblocked', $this->notice[0]); + } + + public function test_unblocking_accounts_clears_the_blocked_flag(): void { + // Pass two users to the module, one that is not blocked, + // and one that is. + $params = [ + 'user' => [ 42, 44 ], + 'blocked' => [ false, true ], + 'page_accounts_block' => true, + ]; + + try { + $this->post('admin/accounts', [], $params); + } catch (RedirectException $redirect) { + $this->assertEquals(z_root() . '/admin/accounts', $redirect->getMessage()); + } + + // We expect the previously unblocked account to be blocked. + $account = get_account_by_id(42); + $this->assertEquals(ACCOUNT_BLOCKED, $account['account_flags'] & ACCOUNT_BLOCKED); + + // We expect the previously blocked account to be unblocked. + $blocked_account = get_account_by_id(44); + $this->assertEquals(0, $blocked_account['account_flags'] & ACCOUNT_BLOCKED); + + $this->assertEquals('2 account blocked/unblocked', $this->notice[0]); + } + + public function test_deleting_accouns_remove_them_from_db(): void { + $params = [ + 'user' => [ 42, 44 ], + 'page_accounts_delete' => true, + ]; + + try { + $this->post('admin/accounts', [], $params); + } catch (RedirectException $redirect) { + $this->assertEquals(z_root() . '/admin/accounts', $redirect->getMessage()); + } + + $this->assertEquals(null, get_account_by_id(42)); + $this->assertEquals(null, get_account_by_id(44)); + } + + public function test_approving_pending_accounts_clears_pending_flag(): void { + + // Catch calls to the php mail function + // + // This is just to get it out of the way, we don't care about + // how many times it's called, or with what args here. + $this->getFunctionMock('Zotlabs\Lib', 'mail') + ->expects($this->any()) + ->willReturn(true); + + $params = [ + 'pending' => [ + $this->fixtures['register'][0]['reg_hash'], + $this->fixtures['register'][1]['reg_hash'] + ], + 'page_accounts_approve' => true, + ]; + + try { + $this->post('admin/accounts', [], $params); + } catch (RedirectException $redirect) { + $this->assertEquals(z_root() . '/admin/accounts', $redirect->getMessage()); + } + + foreach ([45, 46] as $id) { + $account = get_account_by_id($id); + $this->assertEquals(0, $account['account_flags'] & ACCOUNT_PENDING); + } + } + + /** + * Stub the check_form_security_token_ForbiddenOnErr. + */ + protected function stub_check_form_security(): void { + $this->stub_check_security = + $this->getFunctionMock('Zotlabs\Module\Admin', 'check_form_security_token_redirectOnErr') + ->expects($this->once()) + ->with( + $this->identicalTo('/admin/accounts'), + $this->identicalTo('admin_accounts')) + ->willReturn(true); + } + + /** + * Stub the call to is_site_admin in the Admin main module. + */ + protected function stub_is_site_admin(): void { + $this->stub_is_site_admin = + $this->getFunctionMock('Zotlabs\Module', 'is_site_admin') + ->expects($this->once()) + ->willReturn(true); + } + + /** + * Stub the goaway function. + * + * Will throw an RedirectException with the URL being redirected to + * as the exception message. + * + * @throws RedirectException + */ + protected function stub_goaway(): void { + $this->stub_goaway = + $this->getFunctionMock('Zotlabs\Module\Admin', 'goaway') + ->expects($this->once()) + ->willReturnCallback(function (string $uri) { + throw new RedirectException($uri); + }); + } + + protected function stub_notice(): void { + $this->notice = []; + $this->stub_notice = + $this->getFunctionMock('Zotlabs\Module\Admin', 'notice') + ->expects($this->any()) + ->willReturnCallback(function (string $arg) { + $this->notice[] = $arg; + }); + } +} diff --git a/tests/unit/Module/HelpTest.php b/tests/unit/Module/HelpTest.php index 2c1d31570..e1aea1a06 100644 --- a/tests/unit/Module/HelpTest.php +++ b/tests/unit/Module/HelpTest.php @@ -76,13 +76,13 @@ class HelpTest extends \Zotlabs\Tests\Unit\Module\TestCase { public function test_get_request_without_args_redirects_to_about_page(): void { $this->stub_goaway(); $this->expectException(\Zotlabs\Tests\Unit\Module\RedirectException::class); - $this->expectExceptionMessage('about/about'); + $this->expectExceptionMessage('about'); $this->get('help'); } - public function test_getting_locale_with_no_topic_should_redirect_to_about_page_for_locale(): void { - $this->expectRedirectTo('help/de/about/about'); + public function test_getting_locale_with_no_topic_should_redirect_to_about_page(): void { + $this->expectRedirectTo('help/about'); $this->get('help/de'); } diff --git a/tests/unit/Module/ItemTest.php b/tests/unit/Module/ItemTest.php new file mode 100644 index 000000000..b461a3685 --- /dev/null +++ b/tests/unit/Module/ItemTest.php @@ -0,0 +1,56 @@ +<?php +/* + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Module; + +use PHPUnit\Framework\Attributes\TestWith; + +class ItemTest extends TestCase { + + #[TestWith(['application/x-zot+json'])] + #[TestWith(['application/x-zot-activity+json'])] + public function test_request_with_no_args_return_404(string $type): void { + $this->expect_status(404, 'Not found'); + + $_SERVER['HTTP_ACCEPT'] = $type; + $this->get('item'); + } + + #[TestWith(['application/x-zot+json'])] + #[TestWith(['application/x-zot-activity+json'])] + public function test_request_with_non_exiting_idem_id(string $type): void { + $this->expect_status(404, 'Not found'); + + $_SERVER['HTTP_ACCEPT'] = $type; + $this->get('item/non-existing-id'); + } + + /** + * Helper function to mock the `http_status_exit` function. + * + * The request will be terminated by throwing an exception, which + * will also terminate the test case. Iow. control will not return + * to the test case after the request has been made. + * + * @param int $status The expected HTTP status code. + * @param string $description The expected HTTP status description + */ + private function expect_status(int $status, string $description): void { + $this->getFunctionMock('Zotlabs\Module', 'http_status_exit') + ->expects($this->once()) + ->with($this->identicalTo($status), $this->identicalTo($description)) + ->willReturnCallback( + function () { + throw new KillmeException(); + } + ); + + $this->expectException(KillmeException::class); + + } +} diff --git a/tests/unit/Module/MagicTest.php b/tests/unit/Module/MagicTest.php new file mode 100644 index 000000000..2c426bf76 --- /dev/null +++ b/tests/unit/Module/MagicTest.php @@ -0,0 +1,120 @@ +<?php +/** + * Tests for the Magic module + * + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Module; + +use PHPUnit\Framework\Attributes\BackupStaticProperties; +use Zotlabs\Module\Magic; +use App; + +class MagicTest extends TestCase { + + public function test_init_with_no_args(): void { + + // We expect the request to end with a status 400, as we do not + // pass any of the required params. + // + // To catch that, we have to mock the call to `http_status_exit` + // made by the code under test. + $this->getFunctionMock('Zotlabs\Module', 'http_status_exit') + ->expects($this->once()) // Expect the function to be called only once! + ->with( // ... with the following two arguments + $this->identicalTo(400), + $this->identicalTo('Bad Request') + ) + ->willReturnCallback(function () { // Run this function instead when called + throw new KillmeException; // Throw an exception to terminate processing + }); + + // Tell the test system we excpect this exception to be thrown + $this->expectException(KillmeException::class); + + $this->get('magic'); + } + + #[BackupStaticProperties(App::class)] + public function test_local_request_without_delegate(): void { + $baseurl = 'https://hubzilla.test'; + $dest_url = $baseurl . '/channel/testuser'; + + App::set_baseurl($baseurl); + + App::set_observer([ + 'xchan_hash' => 'the hash', + ]); + + // We pass a local URL, and have a valid observer, but as the + // delegate param is not passed, nothing will be done except + // redirecting to the passed dest url. + // + // This should probably return a 400 Invalid Request instead. + $this->expectRedirectTo($dest_url); + + $this->get('magic', [ 'bdest' => bin2hex($dest_url) ]); + } + + #[BackupStaticProperties(App::class)] + public function test_delegate_request_switches_channel_when_allowed(): void { + $baseurl = 'https://hubzilla.test'; + $dest_url = $baseurl . '/channel/testuser'; + + // Set the stage: + // Populate the global static App class with necessary values for the + // code under test + App::set_baseurl($baseurl); + App::$timezone = 'UTC'; + + // Simulate a foreign (to this hub) observer, + App::set_observer([ + 'xchan_hash' => 'foreign hash', + ]); + + // Create the channel the foreign observer wants to access + $result = create_identity([ + 'account_id' => $this->fixtures['account'][0]['account_id'], + 'nickname' => 'testuser', + 'name' => 'Trish Testuser', + ]); + + // Shortcut the permission checks, by saying this observer is allowed + // the delegate privilege over the target channel + insert_hook('perm_is_allowed', function (array &$perm) { + $perm['result'] = true; + }); + + // Add some dummy session data, so we can check that it's being + // pushed to the delegate session. + $original_session = [ + 'data' => 'Just some test session data', + ]; + + $_SESSION = $original_session; + + // Handle redirects manually, since we want to be able to check some + // assertions after the redirect is thrown. + $this->stub_goaway(); + + try { + // Send a request to get delegate privileges for the `testuser` channel + // on the local hub. + $this->get('magic', [ + 'bdest' => bin2hex($dest_url), + 'delegate' => 'testuser@hubzilla.test'] + ); + } catch (RedirectException $e) { + $this->assertEquals($dest_url, $e->getMessage()); + $this->assertEquals($result['channel']['channel_id'], App::$channel['channel_id']); + $this->assertEquals($original_session, $_SESSION['delegate_push']); + $this->assertEquals($result['channel']['channel_id'], $_SESSION['delegate_channel']); + $this->assertEquals('foreign hash', $_SESSION['delegate']); + $this->assertEquals($this->fixtures['account'][0]['account_id'], $_SESSION['account_id']); + } + } +} diff --git a/tests/unit/Module/OwaTest.php b/tests/unit/Module/OwaTest.php new file mode 100644 index 000000000..dbb25c0b5 --- /dev/null +++ b/tests/unit/Module/OwaTest.php @@ -0,0 +1,64 @@ +<?php +/* + * SPDX-FileCopyrightText: 2025 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Module; + +class OwaTest extends TestCase +{ + public function testShouldReturnErrorIfNoAuthorizationHeader(): void + { + // Expect the call to return error + $this->expectJsonResponse([ + 'success' => false, + 'message' => 'Missing or invalid authorization header.' + ]); + + $this->get('owa'); + } + + public function testShouldReturnErrorIfWrongAuthorizationHeader(): void + { + // Expect the call to return error + $this->expectJsonResponse([ + 'success' => false, + 'message' => 'Missing or invalid authorization header.' + ]); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer kjkjhkjhkjh'; + $this->get('owa'); + } + + public function testShouldReturnErrorIfInvalidAuthorizationHeader(): void + { + // Expect the call to return error + $this->expectJsonResponse(['success' => false]); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Signature kjkjhkjhkjh'; + $this->get('owa'); + } + + /** + * Expect the request to be terminated and return a json response. + */ + private function expectJsonResponse(array $data): void + { + $this->getFunctionMock('Zotlabs\Module', 'json_return_and_die') + ->expects($this->once()) + ->with( + $this->identicalTo($data), + $this->identicalTo('application/x-zot+json') + ) + ->willReturnCallback( + function() { + throw new KillmeException(); + } + ); + + $this->expectException(KillmeException::class); + } +} diff --git a/tests/unit/Module/TestCase.php b/tests/unit/Module/TestCase.php index e92bc7083..dd88a5a3b 100644 --- a/tests/unit/Module/TestCase.php +++ b/tests/unit/Module/TestCase.php @@ -10,6 +10,7 @@ namespace Zotlabs\Tests\Unit\Module; +use PHPUnit\Framework\Attributes\After; use Zotlabs\Tests\Unit\UnitTestCase; use App; @@ -25,26 +26,27 @@ class TestCase extends UnitTestCase { // Import PHPMock methods into this class use \phpmock\phpunit\PHPMock; - /** - * Emulate a GET request. - * - * @param string $uri The URI to request. Typically this will be the module - * name, followed by any req args separated by slashes. - * @param array $query Assciative array of query args, with the parameters - * as keys. - */ - protected function get(string $uri, array $query = []): void { - $_GET['q'] = $uri; + protected $killme_stub; + protected $goaway_stub; + + #[After] + public function cleanup_stubs(): void { + $this->killme_stub = null; + $this->goaway_stub = null; + } - if (!empty($query)) { - $_GET = array_merge($_GET, $query); - } + protected function do_request(string $method, string $uri, array $query = [], array $params = []): void { + $_GET['q'] = $uri; + $_GET = array_merge($_GET, $query); + $_POST = $params; - $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_METHOD'] = $method; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['QUERY_STRING'] = "q={$uri}"; + $_SERVER['REQUEST_URI'] = $uri; + // phpcs:disable Generic.PHP.DisallowRequestSuperglobal.Found - $_REQUEST = $_GET; + $_REQUEST = array_merge($_GET, $_POST); // phpcs::enable \App::init(); @@ -55,6 +57,32 @@ class TestCase extends UnitTestCase { } /** + * Emulate a GET request. + * + * @param string $uri The URI to request. Typically this will be the module + * name, followed by any req args separated by slashes. + * @param array $query Assciative array of query args, with the parameters + * as keys. + */ + protected function get(string $uri, array $query = []): void { + $this->do_request('GET', $uri, $query); + } + + /** + * Emulate a POST request. + * + * @param string $uri The URI to request. Typically this will be the module + * name, followed by any req args separated by slashes. + * @param array $query Associative array of query args, with the parameters + * as keys. + * @param array $params Associative array of POST params, with the param names + * as keys. + */ + protected function post(string $uri, array $query = [], array $params = []): void { + $this->do_request('POST', $uri, $query, $params); + } + + /** * Helper to simplify asserting contents in the rendered page. * * @param string $needle The expected string to find. @@ -100,8 +128,7 @@ class TestCase extends UnitTestCase { * @throws KillmeException */ protected function stub_killme(): void { - $killme_stub = $this->getFunctionMock('Zotlabs\Module', 'killme'); - $killme_stub + $this->killme_stub = $this->getFunctionMock('Zotlabs\Module', 'killme') ->expects($this->once()) ->willReturnCallback( function () { @@ -147,8 +174,7 @@ class TestCase extends UnitTestCase { * @throws RedirectException */ protected function stub_goaway(): void { - $goaway_stub = $this->getFunctionMock('Zotlabs\Module', 'goaway'); - $goaway_stub + $this->goaway_stub = $this->getFunctionMock('Zotlabs\Module', 'goaway') ->expects($this->once()) ->willReturnCallback( function (string $uri) { diff --git a/tests/unit/Thumb/EpubthumbTest.php b/tests/unit/Thumb/EpubthumbTest.php new file mode 100644 index 000000000..d381d940e --- /dev/null +++ b/tests/unit/Thumb/EpubthumbTest.php @@ -0,0 +1,158 @@ +<?php +/* + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Thumbs; + +use PHPUnit\Framework\Attributes\{AfterClass, Before, BeforeClass}; +use Zotlabs\Thumbs\Epubthumb; +use Zotlabs\Tests\Unit\UnitTestCase; + +use ZipArchive; + +class EpubthumbTest extends UnitTestCase { + private const TMPDIR = __DIR__ . '/tmp'; + + private Epubthumb $thumbnailer; + + /** + * Create a temp dir to use for the tests in this class. + */ + #[BeforeClass] + static function setupTmpDir(): void { + if (!is_dir(self::TMPDIR)) { + mkdir(self::TMPDIR); + } + } + + /** + * Clean up and remove the temp dir after the tests. + */ + #[AfterClass] + static function cleanupTmpDir(): void { + $files = scandir(self::TMPDIR); + if ($files !== false) { + foreach($files as $f) { + if ($f[0] !== '.') { + unlink(self::TMPDIR . '/' . $f); + } + } + } + rmdir(self::TMPDIR); + } + + /** + * Create the thumbnailer object for tests. + * + * This is run before each test, so that each test has it's own + * instance of the thumbnailer. + */ + #[Before] + function createThumbnailer(): void { + $this->thumbnailer = new Epubthumb(); + } + + /* + * Tests + */ + + public function testEpubThumbMatch(): void { + $this->assertTrue($this->thumbnailer->Match('application/epub+zip')); + $this->assertFalse($this->thumbnailer->Match('application/zip')); + } + + public function testNoThumbnailCreatedForFileThatDontExist(): void { + $this->checkCreateThumbnail(self::TMPDIR . '/nonexisting.epub', false); + } + + public function testNoThumbnailCreatedIfNotAZipArchive(): void { + $filename = self::TMPDIR . '/notazip.epub'; + + file_put_contents($filename, 'This is not a ZIP file!'); + + $this->checkCreateThumbnail($filename, false); + } + + public function testNoThumbnailCreatedIfInvalidEpub(): void { + $filename = self::TMPDIR . '/nocontainer.epub'; + + $epub = new ZipArchive(); + $epub->open($filename, ZipArchive::CREATE); + $epub->addFromString('somefile.txt', 'It was a dark an stormy night...'); + $epub->close(); + + $this->checkCreateThumbnail($filename, false); + } + + public function testNoThumbnailCreatedIfCoverFileMissing(): void { + $filename = self::TMPDIR . '/good.epub'; + + $epub = new ZipArchive(); + $epub->open($filename, ZipArchive::CREATE); + $this->addEpubContainer($epub); + $this->addEpubPackage($epub); + $epub->close(); + + $this->checkCreateThumbnail($filename, false); + } + + public function testCreateCoverFromEpub(): void { + $filename = self::TMPDIR . '/good.epub'; + + $epub = new ZipArchive(); + $epub->open($filename, ZipArchive::CREATE); + $this->addEpubContainer($epub); + $this->addEpubPackage($epub); + $epub->addFile(PROJECT_BASE . '/images/red-koala.png', 'EPUB/cover.png'); + $epub->close(); + + $this->checkCreateThumbnail($filename, true); + } + + /* + * Helper functions + */ + + private function checkCreateThumbnail(string $filename, bool $expectThumbnail): void { + $attach = [ 'content' => $filename ]; + $this->thumbnailer->Thumb($attach, 0); + + $this->assertEquals($expectThumbnail, file_exists($filename . '.thumb')); + } + + private function addEpubContainer(ZipArchive $epub): void { + $xml = <<<XML + <?xml version="1.0" encoding="UTF-8"?> + <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> + <rootfiles> + <rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/> + </rootfiles> + </container> + XML; + + $epub->addEmptyDir('META-INF'); + $epub->addFromString('META-INF/container.xml', $xml); + } + + private function addEpubPackage(ZipArchive $epub): void { + $xml = <<<XML + <?xml version="1.0" encoding="UTF-8"?> + <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-identifier"> + <manifest> + <item + properties="cover-image" + id="ci" + href="cover.png" + media-type="image/png" /> + </manifest> + </package> + XML; + + $epub->addEmptyDir('EPUB'); + $epub->addFromString('EPUB/package.opf', $xml); + } +} diff --git a/tests/unit/UnitTestCase.php b/tests/unit/UnitTestCase.php index afc309205..e3cd22b63 100644 --- a/tests/unit/UnitTestCase.php +++ b/tests/unit/UnitTestCase.php @@ -47,7 +47,7 @@ require_once 'include/dba/dba_transaction.php'; */ class UnitTestCase extends TestCase { protected array $fixtures = array(); - protected ?\DbaTransaction $db_transacton = null; + protected ?\DbaTransaction $db_transaction = null; /** * Connect to the test db, load fixtures and global config. @@ -75,6 +75,14 @@ class UnitTestCase extends TestCase { } /** + * Initialize the global App properties. + */ + #[Before] + protected function init_app(): void { + \App::set_hostname('hubzilla.test'); + } + + /** * Roll back test database to it's original state, cleaning up * any changes from the test. * diff --git a/tests/unit/Widget/HelpindexTest.php b/tests/unit/Widget/HelpindexTest.php index 26aa34104..87042c559 100644 --- a/tests/unit/Widget/HelpindexTest.php +++ b/tests/unit/Widget/HelpindexTest.php @@ -8,6 +8,8 @@ * SPDX-License-Identifier: MIT */ +use PHPUnit\Framework\Attributes\Before; + /** * Test class for testing the Helpindex widget. */ @@ -15,6 +17,8 @@ class HelpindexTest extends \Zotlabs\Tests\Unit\Module\TestCase { use \phpmock\phpunit\PHPMock; + private string $output; + /** * Define the stubs to make sure they work later in the test. * @@ -27,6 +31,12 @@ class HelpindexTest extends \Zotlabs\Tests\Unit\Module\TestCase { self::defineFunctionMock('Zotlabs\Widget', 'file_get_contents'); } + #[Before] + public function setup_state(): void { + // Make sure the output is cleared before running the test + $this->output = ''; + } + public function test_loading_toc(): void { // Stub `file_get_contents` to plant our own content. $fgc_stub = $this->getFunctionMock('Zotlabs\Widget', 'file_get_contents'); diff --git a/tests/unit/Widget/MessagesWidgetTest.php b/tests/unit/Widget/MessagesWidgetTest.php new file mode 100644 index 000000000..ca025ff43 --- /dev/null +++ b/tests/unit/Widget/MessagesWidgetTest.php @@ -0,0 +1,83 @@ +<?php +/* + * SPDX-FileCopyrightText: 2025 The Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen <haraldei@anduin.net> + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\Widget; + +use App; +use Zotlabs\Widget\Messages; +use Zotlabs\Tests\Unit\Module\TestCase; + +class MessagesWidgetTest extends TestCase +{ + use \phpmock\phpunit\PHPMock; + + /** + * List of file tags should be empty if there are no file tags. + */ + public function testNoFileTags(): void + { + $local_channe_stub = $this->getFunctionMock('Zotlabs\Widget', 'local_channel') + ->expects($this->any()) + ->willReturn(42); + + $feature_enabled_stub = $this->getFunctionMock('Zotlabs\Widget', 'feature_enabled') + ->expects($this->any()) + ->willReturn(true); + + $this->renderWidget(); + $this->assertOutputMatches('|<datalist\s+id="data_filetags">\s+</datalist>|'); + } + + /** + * The widget lists file tags that are defined for the channel. + */ + public function testFileTagsAreListed(): void + { + $local_channe_stub = $this->getFunctionMock('Zotlabs\Widget', 'local_channel') + ->expects($this->any()) + ->willReturn(42); + + $feature_enabled_stub = $this->getFunctionMock('Zotlabs\Widget', 'feature_enabled') + ->expects($this->any()) + ->willReturn(true); + + /* + * Add a few tags. + */ + store_item_tag(42, 1, TERM_OBJ_POST, TERM_FILE, 'test_file_tag', ''); + store_item_tag(42, 1, TERM_OBJ_POST, TERM_FILE, 'test_file_tag2', ''); + + $this->renderWidget(); + $this->assertOutputMatches('|<option\s+value="test_file_tag">|'); + $this->assertOutputMatches('|<option\s+value="test_file_tag2">|'); + } + + /** + * Initializes the app and calls the widget code. + */ + private function renderWidget(): void { + $_GET['q'] = 'hq'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + + App::init(); + + $widget = new Messages(); + $this->output = $widget->widget([]); + } + + /** + * Asserts that the output matches a given regex pattern. + * + * If the pattern does not match, the test will be marked as failed. + * + * @param string $pattern The regex that should be matched. + */ + private function assertOutputMatches(string $pattern): void { + $this->assertMatchesRegularExpression($pattern, $this->output); + } +} diff --git a/tests/unit/includes/AccountTest.php b/tests/unit/includes/AccountTest.php index 3978f9d04..66c761ef5 100644 --- a/tests/unit/includes/AccountTest.php +++ b/tests/unit/includes/AccountTest.php @@ -1,9 +1,28 @@ <?php + +use Zotlabs\Tests\Unit\UnitTestCase; + /** * Tests for account handling helper functions. */ +class AccountTest extends UnitTestCase { + + /** + * Test the `get_account_id()` function. + */ + public function test_get_account_id() { + App::set_account(null); + unset($_SESSION['account_id']); + + $this->assertEquals(false, get_account_id(), 'get_account_id() should return false if not authenticated'); + + App::set_account(['account_id' => 36]); + $this->assertEquals(36, get_account_id(), 'get_account_id() should return account from global App object'); + + $_SESSION['account_id'] = 42; + $this->assertEquals(42, get_account_id(), 'get_account_id() should return the account from the session'); + } -class AccountTest extends Zotlabs\Tests\Unit\UnitTestCase { public function test_get_account_by_id_returns_existing_account() { $account = get_account_by_id(42); $this->assertNotFalse($account); diff --git a/tests/unit/includes/BBCodeTest.php b/tests/unit/includes/BBCodeTest.php index 30ea00ba6..982ef4eb9 100644 --- a/tests/unit/includes/BBCodeTest.php +++ b/tests/unit/includes/BBCodeTest.php @@ -23,6 +23,7 @@ namespace Zotlabs\Tests\Unit\includes; +use App; use Zotlabs\Tests\Unit\UnitTestCase; class BBCodeTest extends UnitTestCase { @@ -42,7 +43,7 @@ class BBCodeTest extends UnitTestCase { */ public function test_bbcode_observer(string $src, bool $logged_in, string $lang, string $expected): void { if ($logged_in) { - \App::$observer = [ + App::set_observer([ 'xchan_addr' => '', 'xchan_name' => '', 'xchan_connurl' => '', @@ -50,9 +51,9 @@ class BBCodeTest extends UnitTestCase { // port required in xchan url due to bug in get_rpost_path 'xchan_url' => 'https://example.com:666', - ]; + ]); } else { - \App::$observer = null; + App::set_observer(null); } \App::$language = $lang; @@ -138,7 +139,39 @@ class BBCodeTest extends UnitTestCase { 'del tag' => [ 'some [s]strike through[/s] text', 'some <del>strike through</del> text' - ] + ], + 'naked url is converted to link' => [ + 'example url: https://example.com', + 'example url: <a href="https://example.com" target="_blank" rel="nofollow noopener">https://example.com</a>' + ], + 'naked url followed by newline' => [ + "https://www.example.com\nhave a great day.", + '<a href="https://www.example.com" target="_blank" rel="nofollow noopener">https://www.example.com</a><br />have a great day.', + ], + 'inline naked url' => [ + "This is a link https://example.com/some/path more info.", + 'This is a link <a href="https://example.com/some/path" target="_blank" rel="nofollow noopener">https://example.com/some/path</a> more info.', + ], + 'naked url within code block is not converted to link' => [ + "[code]\nhttp://example.com\n[/code]", + "<pre><code>http://example.com</code></pre>" + ], + 'geo uri is converted to link' => [ + 'example url: [url]geo:37.786971,-122.399677;u=35[/url]', + 'example url: <a href="geo:37.786971,-122.399677;u=35" target="_blank" rel="nofollow noopener">geo:37.786971,-122.399677;u=35</a>' + ], + 'geo uri with label is converted to link' => [ + 'example url: [url=geo:37.786971,-122.399677;u=35(Wikimedia+Foundation)]Wikimedia Foundation[/url]', + 'example url: <a href="geo:37.786971,-122.399677;u=35(Wikimedia+Foundation)" target="_blank" rel="nofollow noopener">Wikimedia Foundation</a>' + ], + 'naked geo uri is converted to link' => [ + 'example url: geo:37.786971,-122.399677;u=35', + 'example url: <a href="geo:37.786971,-122.399677;u=35" target="_blank" rel="nofollow noopener">geo:37.786971,-122.399677;u=35</a>' + ], + 'naked geo uri with label is converted to link' => [ + 'example url: geo:37.78918,-122.40335(Wikimedia+Foundation)', + 'example url: <a href="geo:37.78918,-122.40335(Wikimedia+Foundation)" target="_blank" rel="nofollow noopener">📍Wikimedia Foundation</a>' + ], ]; } @@ -189,7 +222,7 @@ class BBCodeTest extends UnitTestCase { '[rpost=a title]This is the body[/rpost]', true, 'en', - '<a href="https://example.com:666/rpost?f=&title=a+title&body=This+is+the+body" target="_blank" rel="nofollow noopener">https://example.com:666/rpost?f=&title=a+title&body=This+is+the+body</a>', + '<a href="https://example.com:666/rpost?f=&title=a+title&body=This+is+the+body" target="_blank" rel="nofollow noopener">https://example.com:666/rpost?f=&title=a+title&body=This+is+the+body</a>', ], 'unauthenticated observer rpost' => [ '[rpost=a title]This is the body[/rpost]', @@ -223,6 +256,10 @@ class BBCodeTest extends UnitTestCase { "<pre><code>some\n indented\ncode</code></pre>", "[code]some\n indented\ncode[/code]" ], + 'code block with URL' => [ + '<pre><code>\nproxy_pass http://example.com\n</code></pre>', + '[code]\nproxy_pass http://example.com\n[/code]' + ], 'paragraph with a mention and some text' => [ '<p><span class="h-card" translate="no"><a href="https://example.org/@profile" class="u-url mention">@<span>profile</span></a></span> some content</p>', '[url=https://example.org/@profile]@profile[/url] some content' diff --git a/tests/unit/includes/DatetimeTest.php b/tests/unit/includes/DatetimeTest.php new file mode 100644 index 000000000..f8c480449 --- /dev/null +++ b/tests/unit/includes/DatetimeTest.php @@ -0,0 +1,53 @@ +<?php +/** + * tests function from include/datetime.php + * + * @package test.util + */ + +use Zotlabs\Tests\Unit\UnitTestCase; + +class DatetimeTest extends UnitTestCase { + + // Test when the timestamp is in the past + public function test_relative_time_past() { + $now = new DateTime('2024-12-07 00:00:00'); + $timestamp = datetime_convert(date_default_timezone_get(), 'UTC', '2023-12-05 10:30:00'); + $result = relative_time($timestamp, $now); + $this->assertEquals('1 year ago', $result); + } + + // Test when the timestamp is in the future + public function test_relative_time_future() { + $now = new DateTime('2024-12-07 00:00:00'); + $timestamp = datetime_convert(date_default_timezone_get(), 'UTC', '2024-12-09 12:00:00'); + $result = relative_time($timestamp, $now); + $this->assertEquals('in 2 days', $result); + } + + // Test for "now" case (timestamp exactly equal to current time) + public function test_relative_time_now() { + $now = new DateTime('2024-12-07 00:00:00'); + $timestamp = datetime_convert(date_default_timezone_get(), 'UTC', '2024-12-07 00:00:00'); + $result = relative_time($timestamp, $now); + $this->assertEquals('now', $result); + } + + // Test for future time with smaller units (e.g., minutes) + public function test_relative_time_future_minutes() { + $now = new DateTime('2024-12-07 10:30:00'); + $timestamp = datetime_convert(date_default_timezone_get(), 'UTC', '2024-12-07 10:35:00'); + $result = relative_time($timestamp, $now); + $this->assertEquals('in 5 minutes', $result); + } + + // Test for past time with smaller units (e.g., seconds) + public function test_relative_time_past_seconds() { + $now = new DateTime('2024-12-07 10:30:00'); + $timestamp = datetime_convert(date_default_timezone_get(), 'UTC', '2024-12-07 10:29:58'); + $result = relative_time($timestamp, $now); + $this->assertEquals('2 seconds ago', $result); + } +} + + diff --git a/tests/unit/includes/ItemsTest.php b/tests/unit/includes/ItemsTest.php new file mode 100644 index 000000000..1c2fb6725 --- /dev/null +++ b/tests/unit/includes/ItemsTest.php @@ -0,0 +1,132 @@ +<?php +/** + * tests function from include/items.php + * + * @package test.util + */ + +use Zotlabs\Tests\Unit\UnitTestCase; + +class ItemsTest extends UnitTestCase { + /** + * Data provider for item_forwardable function. + * + * @return array + */ + public static function itemForwardableDataProvider() + { + return [ + // Test case: item is unpublished + [ + [ + 'item_unpublished' => 1, + 'item_delayed' => 0, + 'item_blocked' => 0, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Create', + 'postopts' => '', + 'author' => ['xchan_network' => ''] + ], + false // Expected result + ], + // Test case: item is delayed + [ + [ + 'item_unpublished' => 0, + 'item_delayed' => 1, + 'item_blocked' => 0, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Create', + 'postopts' => '', + 'author' => ['xchan_network' => ''] + ], + false + ], + // Test case: item is blocked + [ + [ + 'item_unpublished' => 0, + 'item_delayed' => 0, + 'item_blocked' => 1, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Create', + 'postopts' => '', + 'author' => ['xchan_network' => ''] + ], + false + ], + // Test case: verb is 'Follow' (forbidden verb) + [ + [ + 'item_unpublished' => 0, + 'item_delayed' => 0, + 'item_blocked' => 0, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Follow', + 'postopts' => '', + 'author' => ['xchan_network' => ''] + ], + false + ], + // Test case: postopts contains 'nodeliver' + [ + [ + 'item_unpublished' => 0, + 'item_delayed' => 0, + 'item_blocked' => 0, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Create', + 'postopts' => 'nodeliver', + 'author' => ['xchan_network' => ''] + ], + false + ], + // Test case: actor's network is 'rss' (restricted network) + [ + [ + 'item_unpublished' => 0, + 'item_delayed' => 0, + 'item_blocked' => 0, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Create', + 'postopts' => '', + 'author' => ['xchan_network' => 'rss'] + ], + false + ], + // Test case: no conditions met (should forward) + [ + [ + 'item_unpublished' => 0, + 'item_delayed' => 0, + 'item_blocked' => 0, + 'item_hidden' => 0, + 'item_restrict' => 0, + 'verb' => 'Create', + 'postopts' => '', + 'author' => ['xchan_network' => 'other'] + ], + true + ] + ]; + } + + /** + * Test item_forwardable with various data. + * + * @dataProvider itemForwardableDataProvider + */ + public function testItemForwardable($item, $expected) + { + $this->assertSame($expected, item_forwardable($item)); + } + +} + + diff --git a/tests/unit/includes/LanguageTest.php b/tests/unit/includes/LanguageTest.php index 8f62e71e2..90249c5c8 100644 --- a/tests/unit/includes/LanguageTest.php +++ b/tests/unit/includes/LanguageTest.php @@ -86,16 +86,16 @@ class LanguageTest extends UnitTestCase { 'en-gb', 'British English', [ - 'de' => 'Englisch (Vereinigtes Königreich)', - 'nb' => 'engelsk (Storbritannia)' + 'de' => 'British English', // should be Englisch (Vereinigtes Königreich), seems to be a bug upstream + 'nb' => 'British English' // should be engelsk (Storbritannia), seems to be a bug upstream ] ], 'en-au' => [ 'en-au', 'Australian English', [ - 'de' => 'Englisch (Australien)', - 'nb' => 'engelsk (Australia)' + 'de' => 'Australian English', // should be Englisch (Australien), seems to be a bug upstream + 'nb' => 'Australian English' // should be engelsk (Australia), , seems to be a bug upstream ] ], 'nb' => [ diff --git a/tests/unit/includes/MarkdownTest.php b/tests/unit/includes/MarkdownTest.php index 310130bf1..55dbb4445 100644 --- a/tests/unit/includes/MarkdownTest.php +++ b/tests/unit/includes/MarkdownTest.php @@ -36,7 +36,7 @@ class MarkdownTest extends UnitTestCase { * @dataProvider markdown_to_bbcode_provider */ public function test_markdown_to_bbcode(string $expected, string $src): void { - $this->assertEquals($expected, markdown_to_bb($src)); + $this->assertEquals($expected, markdown_to_bb($src, true, ['preserve_lf' => true])); } public static function markdown_to_bbcode_provider(): array { @@ -54,11 +54,14 @@ class MarkdownTest extends UnitTestCase { 'This is a test of **bold text**, *italic text* and ***bold and italic text***' ], 'multiline text' => [ - 'This text is text wrapped over multiple lines.', + // This is not as expected in markdown, but may be needed + // for compatibility with bbcode behaviour. + "This text is\ntext wrapped\nover multiple\nlines.", "This text is\ntext wrapped\nover multiple\nlines." ], 'text with hard linebreak' => [ - "Line one\nLine two", + // An extra line break is inserted here... + "Line one\n\nLine two", "Line one \nLine two" ], 'paragraphs' => [ @@ -78,21 +81,39 @@ class MarkdownTest extends UnitTestCase { '`some code`' ], 'inline code with wrapped text' => [ - '[code]some code unwrapped[/code]', + // Not sure if the newline should be preseved here? + "[code]some code\nunwrapped[/code]", "`some code\n unwrapped`" ], 'code block no language' => [ - "[code]some code\nover multiple lines[/code]", + "[code]some code\nover multiple lines\n[/code]", "```\nsome code\nover multiple lines\n```" ], 'code block no language indented' => [ - "[code]some code\n over multiple lines\n with indentation[/code]", + // For some reason one space char is eaten on indented lines. + "[code]some code\n over multiple lines\n with indentation\n[/code]", "```\nsome code\n over multiple lines\n with indentation\n```" ], 'code block with language' => [ - "[code=php]<?php\necho phpinfo();[/code]", + "[code=php]<?php\necho phpinfo();\n[/code]", "```php\n<?php\necho phpinfo();\n```" ], + 'code block with URL' => [ + "[code]an example url https://example.com\n[/code]", + "```\nan example url https://example.com\n```" + ], + 'bbcode code block with URL' => [ + "[code]\nproxy_pass http://example.com;\n[/code]", + "[code]\nproxy_pass http://example.com;\n[/code]" + ], + 'naked url followed by newline' => [ + "https://example.com\nhave a great day.", + "https://example.com\nhave a great day.", + ], + 'inline naked url' => [ + 'This is a link https://example.com/some/path more info.', + 'This is a link https://example.com/some/path more info.', + ], ]; } diff --git a/tests/unit/includes/NetworkTest.php b/tests/unit/includes/NetworkTest.php index a41075f25..0d99fc9c3 100644 --- a/tests/unit/includes/NetworkTest.php +++ b/tests/unit/includes/NetworkTest.php @@ -60,7 +60,63 @@ class NetworkTest extends Zotlabs\Tests\Unit\UnitTestCase { ['some.email@example.cancerresearch', true], // And internationalized TLD's - ['some.email@example.شبكة', true] + ['some.email@example.شبكة', true], + + // Allow plus/minus addressing + ['address+tag@example.com', true], + ['address-tag@example.com', true], + ]; + } + + /** + * Test the unparse_url function. + * + */ + public function test_unparse_url_full() + { + $parsed_url = [ + 'scheme' => 'https', + 'host' => 'www.example.com', + 'port' => '8080', + 'user' => 'username', + 'pass' => 'password', + 'path' => '/path', + 'query' => 'param=value', + 'fragment' => 'section' + ]; + + $expected = 'https://username:password@www.example.com:8080/path?param=value#section'; + $this->assertEquals($expected, unparse_url($parsed_url)); + } + + public function test_unparse_url_partial() + { + $parsed_url = [ + 'scheme' => 'http', + 'host' => 'example.com', + 'path' => '/index.php' ]; + + $expected = 'http://example.com/index.php'; + $this->assertEquals($expected, unparse_url($parsed_url)); + } + + public function test_unparse_url_custom() + { + $parsed_url = [ + 'scheme' => 'https', + 'host' => 'www.example.com', + 'port' => '443', + 'path' => '/api' + ]; + + $parts = ['scheme', 'host']; + $expected = 'https://www.example.com'; + $this->assertEquals($expected, unparse_url($parsed_url, $parts)); + } + + public function test_unparse_url_empty() + { + $this->assertEquals('', unparse_url([])); } } diff --git a/tests/unit/includes/PhotodriverTest.php b/tests/unit/includes/PhotodriverTest.php index 34dc058b7..db9883589 100644 --- a/tests/unit/includes/PhotodriverTest.php +++ b/tests/unit/includes/PhotodriverTest.php @@ -20,4 +20,62 @@ class PhotodriverTest extends UnitTestCase { $photo = \photo_factory(file_get_contents('images/hz-16.png'), 'image/png'); $this->assertInstanceOf('Zotlabs\Photo\PhotoGd', $photo); } + + // Helper to create a temporary image file + private function createTempImage($type = 'jpeg'): string + { + $tmp = tempnam(sys_get_temp_dir(), 'img'); + switch ($type) { + case 'png': + $im = imagecreatetruecolor(10, 10); + imagepng($im, $tmp); + imagedestroy($im); + break; + case 'jpeg': + default: + $im = imagecreatetruecolor(10, 10); + imagejpeg($im, $tmp); + imagedestroy($im); + break; + } + return $tmp; + } + + public function testGuessImageTypeFromRawData() + { + $filename = 'irrelevant'; + $data = [ + 'body' => file_get_contents($this->createTempImage('jpeg')) + ]; + $result = guess_image_type($filename, $data); + $this->assertEquals('image/jpeg', $result); + } + + public function testGuessImageTypeFromLocalFile() + { + $file = $this->createTempImage('png'); + $result = guess_image_type($file); + $this->assertEquals('image/png', $result); + unlink($file); + } + + public function testGuessImageTypeFromHeaders() + { + $filename = 'irrelevant'; + $data = [ + 'header' => "Content-Type: image/jpeg\nOther: value" + ]; + $result = guess_image_type($filename, $data); + $this->assertEquals('image/jpeg', $result); + } + + public function testGuessImageTypeUnknownTypeReturnsNull() + { + $filename = 'not_an_image.txt'; + $data = [ + 'body' => 'not an image' + ]; + $result = guess_image_type($filename, $data); + $this->assertNull($result); + } } diff --git a/tests/unit/includes/dba/DbaPdoTest.php b/tests/unit/includes/dba/DbaPdoTest.php new file mode 100644 index 000000000..8a1a2b197 --- /dev/null +++ b/tests/unit/includes/dba/DbaPdoTest.php @@ -0,0 +1,140 @@ +<?php +/** + * Tests for `includes/dba_pdo.php`. + * + * SPDX-FileCopyrightText: 2024 Hubzilla Community + * SPDX-FileContributor: Harald Eilertsen + * + * SPDX-License-Identifier: MIT + */ + +namespace Zotlabs\Tests\Unit\includes; + +use DBA; +use PDO; +use PDOStatement; +use PHPUnit\Framework\Attributes\DataProvider; +use Zotlabs\Tests\Unit\UnitTestCase; + +class DbaPdoTest extends UnitTestCase +{ + public function testInsertingRowWithRturningClauseReturnsInsertedRow(): void + { + // MySQL does not support the `returning` clause, so we skip the test + // for that DB backend. + $this->skipIfMySQL(); + + // Let's manually insert a row in the config table. + // This is just because it's a conventient table to test + // against + $res = q(<<<SQL + INSERT INTO config (cat, k, v) + VALUES ('test', 'a key', 'A value') + RETURNING * + SQL); + + $this->assertIsArray($res); + $this->assertIsArray($res[0]); + $this->assertTrue($res[0]['id'] > 0); + $this->assertEquals('test', $res[0]['cat']); + $this->assertEquals('a key', $res[0]['k']); + $this->assertEquals('A value', $res[0]['v']); + } + + #[DataProvider('insertRowProvider')] + public function testInsertRow(string $table, array $data, string $id): void + { + $res = DBA::$dba->insert($table, $data, $id); + + $this->assertIsArray($res); + + // Make sure the result contains the expected id + $this->assertArrayHasKey($id, $res); + + foreach ($data as $key => $value) { + $this->assertEquals($value, $res[$key]); + } + } + + #[DataProvider('insertRowProvider')] + public function testInsertShouldReturnFalseIfInsertFails( + string $table, + array $data, + string $id + ): void + { + $res1 = DBA::$dba->insert($table, $data, $id); + $this->assertIsArray($res1); + + // Inserting the same row again should fail. + $res2 = DBA::$dba->insert($table, $data, $id); + $this->assertFalse($res2); + } + + /** + * Dataprovider for testInertRow. + * + * @return array An array of [ $table, $data, $id ] elements. + */ + public static function insertRowProvider(): array + { + return [ + 'table with numeric primary id' => [ + 'config', + [ 'cat' => 'test', 'k' => 'a key', 'v' => 'A value' ], + 'id', + ], + 'table with text primary id' => [ + 'cache', + [ 'k' => 'some key', 'v' => 'cached value', 'updated' => date('Y-m-d H:i:s')], + 'k', + ], + ]; + } + + public function testUpdateRow(): void + { + // Let's fetch a row from the config table + $res = q("SELECT * FROM config WHERE cat = 'system' AND k = 'baseurl'"); + + $this->assertIsArray($res); + $this->assertIsArray($res[0]); + + $row = $res[0]; + + // Update the baseurl + $updated = DBA::$dba->update( + 'config', + [ 'v' => 'https://some.other_site.test/' ], + 'id', + $row['id'] + ); + + $this->assertTrue($updated); + + // Verify that the record was updated + $updated_res = q("SELECT * FROM config WHERE cat = 'system' AND k = 'baseurl'"); + $this->assertIsArray($updated_res); + + $updated_row = $updated_res[0]; + + $this->assertIsArray($updated_row); + $this->assertEquals($row['id'], $updated_row['id']); + $this->assertEquals('system', $updated_row['cat']); + $this->assertEquals('baseurl', $updated_row['k']); + $this->assertEquals('https://some.other_site.test/', $updated_row['v']); + } + + /** + * Mark the test as skipped if the current db is MySQL. + */ + private function skipIfMySQL(): void { + $driver = DBA::$dba->db->getAttribute(PDO::ATTR_DRIVER_NAME); + $version = DBA::$dba->db->getAttribute(PDO::ATTR_SERVER_VERSION); + + if ($driver === 'mysql' && stripos($version, 'mariadb') === false) { + $this->markTestSkipped("RETURNING clause not supported for {$driver}"); + } + + } +} diff --git a/tests/unit/includes/dba/_files/account.yml b/tests/unit/includes/dba/_files/account.yml index 88e59056e..9c3d00ec8 100644 --- a/tests/unit/includes/dba/_files/account.yml +++ b/tests/unit/includes/dba/_files/account.yml @@ -3,9 +3,26 @@ account: account_id: 42 account_email: "hubzilla@example.com" account_language: "no" + account_level: 5 account_flags: 0 - account_id: 43 account_email: "hubzilla@example.org" account_language: "de" + account_level: 5 account_flags: 1 + - + account_id: 44 + account_email: "blocked@example.org" + account_level: 5 + account_flags: 2 + - + account_id: 45 + account_email: "pending@example.org" + account_level: 5 + account_flags: 0x10 + - + account_id: 46 + account_email: "unverified@example.org" + account_level: 5 + account_flags: 0x11 diff --git a/tests/unit/includes/dba/_files/register.yml b/tests/unit/includes/dba/_files/register.yml new file mode 100644 index 000000000..2ef1a5365 --- /dev/null +++ b/tests/unit/includes/dba/_files/register.yml @@ -0,0 +1,20 @@ +--- +register: + - + reg_vital: 1 + reg_flags: 0x10 + reg_did2: 'verified@example.com' + reg_email: 'verified@example.com' + reg_hash: '123' + reg_uid: 45 + reg_pass: 'verify' + reg_stuff: '' + - + reg_vital: 1 + reg_flags: 0x11 + reg_did2: 'unverified@example.com' + reg_email: 'unverified@example.com' + reg_hash: '666' + reg_uid: 46 + reg_pass: 'verify' + reg_stuff: '' |