3, 2 => null, 3 => null, 4 => null] as $para => $chapterLen) { (new PaliText)->forceFill([ 'book' => 9301, 'paragraph' => $para, 'level' => $chapterLen ? 2 : 100, 'class' => '', 'toc' => '', 'text' => '', 'html' => '', 'lenght' => 10, 'chapter_len' => $chapterLen, 'pcd_book_id' => 0, 'uid' => (string) Str::uuid(), ])->save(); } } function makeTextDiscussion(string $sentenceUid, array $attrs = []): Discussion { return Discussion::forceCreate(array_merge([ 'res_type' => 'sentence', 'res_id' => $sentenceUid, 'type' => 'issue', 'title' => 't', 'content' => 'body', 'editor_uid' => makeStudio('td-'.Str::random(8)), ], $attrs)); } function tipitakaDiscussionsUrl(string $channel, string $query = ''): string { return "/api/v3/tipitaka-discussions/{$channel}".($query === '' ? '' : "?{$query}"); } it('lists topics in a chapter in text order, with sentence coordinates', function () { makeDiscussionChapter(); $owner = makeStudio('owner'); $channel = makeChannel($owner); $p3 = makeSentence($channel, 9301, 3, 1, 'third'); $p1b = makeSentence($channel, 9301, 1, 9, 'first, later sentence'); $p1a = makeSentence($channel, 9301, 1, 2, 'first'); $outside = makeSentence($channel, 9301, 4, 1, 'outside the chapter'); $c = makeTextDiscussion($p3->uid, ['title' => 'c']); $b = makeTextDiscussion($p1b->uid, ['title' => 'b']); $a = makeTextDiscussion($p1a->uid, ['title' => 'a', 'type' => 'glossary']); makeTextDiscussion($outside->uid); makeTextDiscussion($p1a->uid, ['parent' => $a->id]); // 回复不列 $json = $this->getJson(tipitakaDiscussionsUrl($channel, 'book=9301&chapter=1')) ->assertOk() ->json(); expect(array_column($json['data'], 'id'))->toBe([$a->id, $b->id, $c->id]) ->and($json['data'][0]['sentence'])->toBe(['book' => 9301, 'paragraph' => 1, 'word_start' => 2, 'word_end' => 2]) ->and($json['data'][0])->toHaveKeys(['id', 'type', 'title', 'status', 'editor', 'abilities', 'reply_count', 'pos_start']) ->and($json['data'][0]['reply_count'])->toBe(1) ->and($json['meta']['total'])->toBe(3); }); it('narrows by paragraph range and by the whole book or channel', function () { $channel = makeChannel(makeStudio('owner')); foreach ([1, 2, 3] as $para) { makeTextDiscussion(makeSentence($channel, 9302, $para, 1, "p{$para}")->uid, ['title' => "p{$para}"]); } makeTextDiscussion(makeSentence($channel, 9303, 1, 1, 'other book')->uid, ['title' => 'other book']); $titles = fn (string $query) => array_column($this->getJson(tipitakaDiscussionsUrl($channel, $query))->assertOk()->json('data'), 'title'); expect($titles('book=9302¶=2&to=3'))->toBe(['p2', 'p3']) ->and($titles('book=9302¶=2'))->toBe(['p2']) ->and($titles('book=9302'))->toBe(['p1', 'p2', 'p3']) ->and($titles(''))->toBe(['p1', 'p2', 'p3', 'other book']); }); it('only lists topics on sentences of this channel', function () { $owner = makeStudio('owner'); $mine = makeChannel($owner, 'mine'); $other = makeChannel($owner, 'other'); makeTextDiscussion(makeSentence($mine, 9304, 1, 1, 'mine')->uid, ['title' => 'mine']); makeTextDiscussion(makeSentence($other, 9304, 1, 1, 'other')->uid, ['title' => 'other']); $this->getJson(tipitakaDiscussionsUrl($mine, 'book=9304')) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.title', 'mine'); $this->getJson(tipitakaDiscussionsUrl((string) Str::uuid()))->assertOk()->assertJsonCount(0, 'data'); }); it('lists only threads by default, and filters by several types, status and severity', function () { $owner = makeStudio('owner'); $channel = makeChannel($owner); $sent = makeSentence($channel, 9305, 1, 1, 'text')->uid; makeTextDiscussion($sent, ['type' => 'issue', 'severity' => 'error', 'title' => 'issue-error']); makeTextDiscussion($sent, ['type' => 'issue', 'severity' => 'warning', 'status' => 'close', 'title' => 'issue-closed']); makeTextDiscussion($sent, ['type' => 'glossary', 'title' => 'glossary']); makeTextDiscussion($sent, ['type' => 'discussion', 'title' => 'discussion']); makeTextDiscussion($sent, ['type' => 'note', 'title' => 'note']); $titles = fn (string $query) => array_column( $this->getJson(tipitakaDiscussionsUrl($channel, "book=9305&{$query}"), authHeader($owner))->assertOk()->json('data'), 'title'); expect($titles(''))->toEqualCanonicalizing(['issue-error', 'issue-closed', 'glossary', 'discussion']) // 批注不在默认里 ->and($titles('type=issue,glossary'))->toEqualCanonicalizing(['issue-error', 'issue-closed', 'glossary']) ->and($titles('type=note'))->toBe(['note']) ->and($titles('type=issue&status=active'))->toBe(['issue-error']) ->and($titles('type=issue&severity=warning'))->toBe(['issue-closed']); }); it('keeps tombstones of deleted topics that still have replies', function () { $channel = makeChannel(makeStudio('owner')); $sent = makeSentence($channel, 9306, 1, 1, 'text')->uid; $withReply = makeTextDiscussion($sent, ['title' => 'tombstone']); makeTextDiscussion($sent, ['parent' => $withReply->id]); $withReply->delete(); makeTextDiscussion($sent, ['title' => 'gone'])->delete(); $this->getJson(tipitakaDiscussionsUrl($channel, 'book=9306')) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $withReply->id) ->assertJsonPath('data.0.title', null); }); it('applies the same visibility as the per-target list', function () { $owner = makeStudio('owner'); $channel = makeChannel($owner); $sent = makeSentence($channel, 9307, 1, 1, 'text')->uid; $basic = makeStudio('basic'); UserInfo::where('userid', $basic)->update(['role' => json_encode(['basic'])]); makeTextDiscussion($sent, ['type' => 'discussion', 'title' => 'private-others']); makeTextDiscussion($sent, ['type' => 'discussion', 'title' => 'private-mine', 'editor_uid' => $basic]); makeTextDiscussion($sent, ['type' => 'issue', 'title' => 'issue']); $titles = fn (array $headers) => array_column( $this->getJson(tipitakaDiscussionsUrl($channel, 'book=9307'), $headers)->assertOk()->json('data'), 'title'); expect($titles([]))->toBe(['issue']) // 匿名:看不到 discussion ->and($titles(authHeader($basic)))->toEqualCanonicalizing(['private-mine', 'issue']) // basic:只看得到自己的 discussion ->and($titles(authHeader($owner)))->toHaveCount(3); // channel 编辑者:全部 }); it('rejects invalid parameters', function () { makeDiscussionChapter(); $channel = makeChannel(makeStudio('owner')); $get = fn (string $query) => $this->getJson(tipitakaDiscussionsUrl($channel, $query)); $get('chapter=1')->assertStatus(422)->assertJsonValidationErrors('book'); $get('book=9301&chapter=1¶=2')->assertStatus(422)->assertJsonValidationErrors('chapter'); $get('book=9301¶=3&to=2')->assertStatus(422)->assertJsonValidationErrors('to'); $get('type=issue,bogus')->assertStatus(422)->assertJsonValidationErrors('type'); $get('per_page=500')->assertStatus(422)->assertJsonValidationErrors('per_page'); $get('book=9301&chapter=99')->assertNotFound(); // 没有这一段(同 tipitaka-reading) $this->getJson('/api/v3/tipitaka-discussions/not-a-uuid')->assertNotFound(); }); it('does not run a query per row', function () { $channel = makeChannel(makeStudio('owner')); $countQueriesFor = function (int $rows) use ($channel) { $book = 9400 + $rows; foreach (range(1, $rows) as $i) { makeTextDiscussion(makeSentence($channel, $book, $i, 1, "s{$i}")->uid, ['editor_uid' => makeStudio("n-{$book}-{$i}")]); } $this->getJson(tipitakaDiscussionsUrl($channel, "book={$book}"))->assertOk(); // 预热 UserService 等缓存 DB::flushQueryLog(); DB::enableQueryLog(); $this->getJson(tipitakaDiscussionsUrl($channel, "book={$book}"))->assertOk()->assertJsonCount($rows, 'data'); $count = count(DB::getQueryLog()); DB::disableQueryLog(); return $count; }; expect($countQueriesFor(12))->toBe($countQueriesFor(2)); });