コード', $c['html']);
$test->assertStringContains('<script>', $c['html']);
$test->assertFalse(str_contains($c['html'], 'assertEquals(['comment:alice1234'], $types('admin1234'));
$test->assertEquals([], $types('alice1234'), '自分には通知しない');
});
$test->it('返信は返信先の人に通知し、深さ付きで表示するはず', function($test) use ($admin, $alice, $bob, $types, $thread): void {
$first = array_key_first(Comments::all($thread));
$reply = Comments::addLocal($thread, '記事', $bob, '返信です', $first)->data['id'];
$test->assertEquals(['reply:bob12345'], $types('alice1234'));
$test->assertEquals(['comment:bob12345', 'comment:alice1234'], $types('admin1234'));
// 管理者への返信は「返信」の通知だけ
Comments::addLocal($thread, '記事', $admin, '管理者です', $reply);
$own = Comments::addLocal($thread, '記事', $admin, '自分のコメント')->data['id'];
Comments::addLocal($thread, '記事', $bob, '管理者への返信', $own);
$test->assertEquals(['reply:bob12345', 'comment:bob12345', 'comment:alice1234'], $types('admin1234'));
$view = Comments::view($thread, $bob);
$test->assertEquals([0, 1, 2, 0, 1], array_column($view, 'depth'));
$test->assertEquals('返信です
', $view[1]['html']); $test->assertTrue($view[1]['canDelete'], '自分のコメントは削除出来る'); $test->assertFalse($view[0]['canDelete'], '他人のコメントは削除出来ない'); $test->assertFalse($view[1]['canLike'], '自分のコメントにはいいね出来ない'); $test->assertTrue($view[0]['canLike']); $test->assertTrue(Comments::view($thread, $admin)[0]['canDelete'], '管理者は全部削除出来る'); $test->assertFalse(Comments::view($thread, null)[0]['canReply'], 'ログインしていなければ返信出来ない'); $test->assertEquals(5, Comments::count($thread)); }); $test->it('いいねは切り替えで、通知は重複しないはず', function($test) use ($alice, $bob, $types, $thread): void { $first = array_key_first(Comments::all($thread)); $test->assertTrue(Comments::like($thread, $first, $bob)->data['liked']); $test->assertFalse(Comments::like($thread, $first, $bob)->data['liked']); $test->assertTrue(Comments::like($thread, $first, $bob)->data['liked']); $test->assertEquals(['like:bob12345', 'reply:bob12345'], $types('alice1234')); $test->assertFalse(Comments::like($thread, $first, $alice)->isSuccess, '自分のコメント'); $test->assertFalse(Comments::like($thread, 'nai', $bob)->isSuccess); $v = Comments::view($thread, $bob)[0]; $test->assertEquals(1, $v['likes']); $test->assertTrue($v['liked']); }); $test->it('通知を既読に出来るはず', function($test) use ($thread): void { $test->assertEquals(2, Notifications::unreadCount('alice1234')); $n = Comments::present(Notifications::list('alice1234')[0]); $test->assertStringContains('いいね', $n['label']); $test->assertStringContains("/{$thread}#c-", $n['page']); Notifications::markRead('alice1234'); $test->assertEquals(0, Notifications::unreadCount('alice1234')); $test->assertEquals(2, count(Notifications::list('alice1234'))); }); $test->it('返信のあるコメントは中身だけ消し、返信が無くなれば完全に消すはず', function($test) use ($admin, $alice, $bob, $thread): void { $ids = array_keys(Comments::all($thread)); $test->assertFalse(Comments::delete($thread, $ids[0], $bob)->isSuccess, '他人のコメントは消せない'); $test->assertTrue(Comments::delete($thread, $ids[0], $alice)->isSuccess); $c = Comments::all($thread)[$ids[0]]; $test->assertTrue($c['deleted']); $test->assertFalse(isset($c['source']) || isset($c['html']), '本文は残さない'); $test->assertEquals(4, Comments::count($thread)); $test->assertTrue(Comments::view($thread, null)[0]['deleted']); $test->assertFalse(Comments::addLocal($thread, '記事', $bob, '返信', $ids[0])->isSuccess, '削除されたコメントには返信出来ない'); // 返信の返信(管理者)→返信(bob)の順に消すと、中身を消したaliceのコメントも消える $test->assertTrue(Comments::delete($thread, $ids[1], $bob)->isSuccess); $test->assertTrue(Comments::all($thread)[$ids[1]]['deleted']); $test->assertTrue(Comments::delete($thread, $ids[2], $admin)->isSuccess); $left = Comments::all($thread); $test->assertFalse(isset($left[$ids[0]]) || isset($left[$ids[1]]) || isset($left[$ids[2]])); $test->assertEquals(2, count($left)); }); }); $test->describe('Fediverseからの返信', function($test) use ($admin, $alice, $types): void { if (!Comments::enabled() || !ACTIVITYPUB_ENABLED) $test->skipAll('COMMENTS_ENABLED又はACTIVITYPUB_ENABLEDが無効'); $post = ['title' => '英語の記事', 'date' => '2025-01-01 10:00', 'thumbnail' => '', 'category' => [], 'uuid' => 'c0ffee00-0000-4000-8000-000000000001', 'preview' => '本文', 'slug' => 'english-post', 'section' => 'enblog']; $ap = new Activitypub([$post]); $target = (string)$ap->objectId($post); $carol = 'https://1.1.1.1/users/carol'; Remote::cacheActor(['id' => $carol, 'type' => 'Person', 'preferredUsername' => 'carol', 'name' => 'Carol C', 'url' => 'https://1.1.1.1/@carol', 'inbox' => 'https://1.1.1.1/users/carol/inbox', 'icon' => ['type' => 'Image', 'url' => 'https://1.1.1.1/carol.png']]); $note = fn(string $id, string $inReplyTo, array $extra = []) => $extra + [ 'id' => "https://1.1.1.1/users/carol/statuses/{$id}", 'type' => 'Note', 'attributedTo' => $carol, 'inReplyTo' => $inReplyTo, 'to' => [Fedi::PUBLIC], 'cc' => [], 'content' => '@'.FEDIINFO['actor'].' 面白い記事!
', ]; $create = fn(array $obj) => ['type' => 'Create', 'actor' => $carol, 'object' => $obj]; $thread = 'enblog/english-post'; $test->it('ブログ毎に違うNoteのIDのはず', function($test) use ($post, $target): void { $test->assertStringContains('/ap/objects/enblog/'.$post['uuid'], $target); $test->assertNull((new Activitypub([$post]))->objectId(['section' => 'enblog'] + ['uuid' => 'nai'] + $post)); $blog = ['section' => 'blog'] + $post; $test->assertStringContains('/ap/objects/'.$post['uuid'], (string)(new Activitypub([$blog]))->objectId($blog)); }); $test->it('記事への返信をコメントにして、管理者に通知するはず', function($test) use ($ap, $note, $create, $target, $thread, $carol, $types): void { $test->assertTrue($ap->handleComments($create($note('1', $target)))); $all = Comments::all($thread); $test->assertEquals(1, count($all)); $c = array_values($all)[0]; $test->assertEquals('面白い記事!
', $c['html'], 'ブログのアクターへのメンションとscriptは消す'); $test->assertEquals('@carol@1.1.1.1', $c['author']['handle']); $test->assertEquals('Carol C', $c['author']['name']); $test->assertEquals('https://1.1.1.1/@carol', $c['author']['url']); $test->assertEquals("comment:{$carol}", $types('admin1234')[0]); $test->assertEquals('英語の記事', Notifications::list('admin1234')[0]['title']); // 再送されても1つ $test->assertTrue($ap->handleComments($create($note('1', $target)))); $test->assertEquals(1, Comments::count($thread)); }); $test->it('関係の無い・非公開・偽装した返信は無視するはず', function($test) use ($ap, $note, $create, $target, $thread, $carol): void { $test->assertFalse($ap->handleComments($create($note('2', 'https://8.8.8.8/notes/1'))), '他の投稿への返信'); $test->assertFalse($ap->handleComments($create($note('3', $target, ['to' => [$carol.'/followers']]))), 'フォロワー限定'); $test->assertFalse($ap->handleComments($create($note('4', $target, ['to' => [Fedi::blogActorUrl()]]))), 'DM'); $test->assertFalse($ap->handleComments($create($note('5', $target, ['attributedTo' => 'https://1.1.1.1/users/dave']))), '作者が違う'); $test->assertFalse($ap->handleComments($create($note('6', $target, ['id' => 'https://8.8.8.8/notes/6']))), '他のサーバーのID'); $test->assertFalse($ap->handleComments(['type' => 'Like', 'actor' => $carol, 'object' => $target])); $test->assertEquals(1, Comments::count($thread)); }); $test->it('リモートのコメントへの返信と、ローカルのユーザーの返信を繋げるはず', function($test) use ($ap, $note, $create, $thread, $alice, $carol, $types): void { $first = array_key_first(Comments::all($thread)); $mine = Comments::addLocal($thread, '英語の記事', $alice, 'Carolさんへ', $first)->data['id']; $test->assertTrue($ap->handleComments($create($note('7', 'https://1.1.1.1/users/carol/statuses/1')))); $view = Comments::view($thread, $alice); $test->assertEquals([0, 1, 1], array_column($view, 'depth')); $test->assertTrue($view[0]['who']['remote']); // リモートの画像は直接読み込まない(マルチユーザーではこのサイト経由) if (Fedi::enabled()) $test->assertStringContains('/microblog/media?u=', $view[0]['who']['avatar']); else $test->assertEquals(ass_url('user/noicon.png'), $view[0]['who']['avatar']); $test->assertTrue($view[0]['canLike'], 'リモートのコメントにもいいね出来る'); $test->assertFalse(in_array("reply:{$carol}", $types('alice1234'), true), 'aliceへの返信ではない'); $test->assertEquals(["comment:{$carol}", 'comment:alice1234'], array_slice($types('admin1234'), 0, 2), '管理者には全部のコメント'); }); $test->it('返信のコレクションはFediverseからの記事への直接の返信だけのはず', function($test) use ($ap, $post, $thread, $target): void { $json = json_decode($ap->getReplies($post['uuid'], 'enblog'), true); $test->assertEquals("{$target}/replies", $json['id']); $test->assertEquals('OrderedCollection', $json['type']); $test->assertEquals(['https://1.1.1.1/users/carol/statuses/1'], $json['orderedItems'], 'ローカルのコメントと返信への返信は含まない'); $test->assertEquals(1, $json['totalItems']); $test->assertEquals('', $ap->getReplies($post['uuid']), 'blogには無い'); $test->assertEquals(['https://1.1.1.1/users/carol/statuses/7'], Comments::fediReplies($thread, array_key_first(Comments::all($thread)))); }); $test->it('本人の編集と削除だけを反映するはず', function($test) use ($ap, $note, $thread, $carol): void { $edited = $note('1', '', ['content' => '編集しました
']); $test->assertFalse($ap->handleComments(['type' => 'Update', 'actor' => 'https://1.1.1.1/users/dave', 'object' => $edited])); $test->assertTrue($ap->handleComments(['type' => 'Update', 'actor' => $carol, 'object' => $edited])); $c = array_values(Comments::all($thread))[0]; $test->assertEquals('編集しました
', $c['html']); $test->assertTrue(isset($c['edited'])); $test->assertFalse($ap->handleComments(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/dave', 'object' => 'https://1.1.1.1/users/carol/statuses/7'])); $test->assertTrue($ap->handleComments(['type' => 'Delete', 'actor' => $carol, 'object' => ['id' => 'https://1.1.1.1/users/carol/statuses/7', 'type' => 'Tombstone']])); $test->assertEquals(2, Comments::count($thread)); }); $test->it('アカウントの削除で全部の返信を消すはず', function($test) use ($ap, $thread, $carol): void { $test->assertTrue($ap->handleComments(['type' => 'Delete', 'actor' => $carol, 'object' => $carol])); $view = Comments::view($thread, null); // 返信のあるコメントは「削除されました」として残る $test->assertEquals([true, false], array_column($view, 'deleted')); $test->assertNull(Comments::findRemote('https://1.1.1.1/users/carol/statuses/1')); }); }); $test->describe('Fediverseのロールを持つユーザーのコメント', function($test) use ($admin, $alice, $bob, $tmp): void { if (!Comments::enabled() || !ACTIVITYPUB_MULTIUSER) { $test->skipAll('COMMENTS_ENABLED又はマルチユーザーモードが無効'); } else if (!is_readable((string)FEDIINFO['pubkey'])) { $test->skipAll('ブログのアカウントの公開鍵(FEDIINFOのpubkey)が無い'); } // ロールを外されたユーザー(fediDefaultがあり、FEDIVERSEのビットが無い) file_put_contents("{$tmp}/user/6.carol1234.json", json_encode(['id' => 6, 'username' => 'carol1234', 'displayname' => '', 'namecolor' => '', 'avatar' => '', 'gender' => -1, 'role' => \Roles::MEMBER, 'fediDefault' => true])); $carol = (object)['id' => 6, 'username' => 'carol1234', 'role' => \Roles::MEMBER]; $post = ['title' => '連合する記事', 'date' => '2025-01-01 10:00', 'thumbnail' => '', 'category' => [], 'uuid' => 'c0ffee00-0000-4000-8000-000000000002', 'preview' => '本文', 'slug' => 'fedi-post', 'section' => 'enblog']; $ap = new Activitypub([$post]); $postNote = ACTIVITYPUB_MULTIUSER ? json_decode($ap->getObject($post['uuid'], 'enblog'), true) : []; $thread = 'enblog/fedi-post'; $noteOf = fn(string $thread, string $id) => Notes::get((string)(Fedi::localNoteId((string)(Comments::all($thread)[$id]['apId'] ?? ''))[1] ?? '')); $ids = new \ArrayObject(); $test->it('ロールを持つユーザーのコメントは本人の投稿として記事に返信するはず', function($test) use ($ap, $post, $postNote, $thread, $alice, $noteOf, $ids): void { $res = Comments::addLocal($thread, '連合する記事', $alice, 'こんにちは **世界**', null, $postNote); $test->assertTrue($res->isSuccess, (string)$res->message); $ids['alice'] = $res->data['id']; $c = Comments::all($thread)[$ids['alice']]; $test->assertStringContains(Fedi::actorUrl('alice1234').'/notes/', (string)($c['apId'] ?? '')); $note = $noteOf($thread, $ids['alice']); $test->assertEquals($postNote['id'], $note['inReplyTo']); $test->assertEquals('public', $note['visibility']); $test->assertEquals('こんにちは **世界**', $note['source']); $test->assertEquals([$c['apId']], json_decode($ap->getReplies($post['uuid'], 'enblog'), true)['orderedItems']); $test->assertEquals($ids['alice'], Comments::findRemote($c['apId'])[1], 'Fediverseからの返信を繋げられる'); }); $test->it('ロールが無いユーザーと連合しない記事のコメントは送らないはず', function($test) use ($postNote, $thread, $carol, $alice, $ids): void { $res = Comments::addLocal($thread, '連合する記事', $carol, 'ロール無し', $ids['alice'], $postNote); $test->assertTrue($res->isSuccess, (string)$res->message); $ids['carol'] = $res->data['id']; $test->assertFalse(isset(Comments::all($thread)[$ids['carol']]['apId'])); $id = Comments::addLocal('tutorial/linalg/01-test', '勉強会', $alice, '勉強会のコメント')->data['id']; $test->assertFalse(isset(Comments::all('tutorial/linalg/01-test')[$id]['apId'])); $test->assertEquals(1, count(Comments::fediReplies($thread))); }); $test->it('返信はFediverseにある一番近い親コメントへの返信のはず', function($test) use ($postNote, $thread, $bob, $noteOf, $ids): void { // carolのコメントはFediverseに無いので、その親(alice)への返信にする $ids['bob'] = Comments::addLocal($thread, '連合する記事', $bob, 'carolさんへ', $ids['carol'], $postNote)->data['id']; $alice = Comments::all($thread)[$ids['alice']]['apId']; $test->assertEquals($alice, $noteOf($thread, $ids['bob'])['inReplyTo']); $test->assertEquals([Comments::all($thread)[$ids['bob']]['apId']], Comments::fediReplies($thread, $ids['carol'])); }); $test->it('マイクロブログで編集・削除するとコメントも変わるはず', function($test) use ($postNote, $thread, $bob, $noteOf, $ids): void { $note = $noteOf($thread, $ids['bob']); $test->assertTrue(Social::edit('bob12345', $note['id'], '編集しました')->isSuccess); $c = Comments::all($thread)[$ids['bob']]; $test->assertStringContains('編集しました', $c['html']); $test->assertTrue(isset($c['edited'])); $test->assertTrue(Social::delete('bob12345', $note['id'])->isSuccess); $test->assertFalse(isset(Comments::all($thread)[$ids['bob']])); }); $test->it('コメントを削除すると投稿も消えるはず', function($test) use ($thread, $alice, $admin, $noteOf, $ids): void { $apId = Comments::all($thread)[$ids['alice']]['apId']; $noteId = Fedi::localNoteId($apId)[1]; $test->assertTrue(Comments::delete($thread, $ids['alice'], $admin)->isSuccess, '管理者が消しても本人の投稿を消す'); $test->assertNull(Notes::get($noteId)); $test->assertTrue(Comments::all($thread)[$ids['alice']]['deleted'], 'carolの返信があるので中身だけ消す'); $test->assertNull(Comments::findRemote($apId)); $test->assertEquals([], Comments::fediReplies($thread)); }); }); // 一時ディレクトリを削除する $rm = function(string $dir) use (&$rm): void { foreach (scandir($dir) ?: [] as $f) { if ($f === '.' || $f === '..') continue; is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}"); } @rmdir($dir); }; $rm($tmp); Comments::$dataDir = Notifications::$dataDir = Fedi::$dataDir = Fedi::$userDir = null; $test->printSummary();