TipitakaDiscussionV3Test.php 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. <?php
  2. use App\Models\Discussion;
  3. use App\Models\PaliText;
  4. use App\Models\UserInfo;
  5. use Illuminate\Foundation\Testing\RefreshDatabase;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Str;
  8. uses(RefreshDatabase::class);
  9. /**
  10. * v3 tipitaka-discussions:按经文范围(channel + 书 / 章 / 段落区间)列讨论。
  11. *
  12. * 这里断言的是形状而非契约(spectator 未安装,见 v3-resource skill 第 3 步)。
  13. *
  14. * GET /api/v3/tipitaka-discussions/{channel}?book=&chapter=|para=&to=&type=&status=&severity=
  15. */
  16. /** book 9301 的一章:起始段 1,长 3 段(1–3);第 4 段在章外 */
  17. function makeDiscussionChapter(): void
  18. {
  19. foreach ([1 => 3, 2 => null, 3 => null, 4 => null] as $para => $chapterLen) {
  20. (new PaliText)->forceFill([
  21. 'book' => 9301, 'paragraph' => $para, 'level' => $chapterLen ? 2 : 100,
  22. 'class' => '', 'toc' => '', 'text' => '', 'html' => '', 'lenght' => 10,
  23. 'chapter_len' => $chapterLen, 'pcd_book_id' => 0, 'uid' => (string) Str::uuid(),
  24. ])->save();
  25. }
  26. }
  27. function makeTextDiscussion(string $sentenceUid, array $attrs = []): Discussion
  28. {
  29. return Discussion::forceCreate(array_merge([
  30. 'res_type' => 'sentence',
  31. 'res_id' => $sentenceUid,
  32. 'type' => 'issue',
  33. 'title' => 't',
  34. 'content' => 'body',
  35. 'editor_uid' => makeStudio('td-'.Str::random(8)),
  36. ], $attrs));
  37. }
  38. function tipitakaDiscussionsUrl(string $channel, string $query = ''): string
  39. {
  40. return "/api/v3/tipitaka-discussions/{$channel}".($query === '' ? '' : "?{$query}");
  41. }
  42. it('lists topics in a chapter in text order, with sentence coordinates', function () {
  43. makeDiscussionChapter();
  44. $owner = makeStudio('owner');
  45. $channel = makeChannel($owner);
  46. $p3 = makeSentence($channel, 9301, 3, 1, 'third');
  47. $p1b = makeSentence($channel, 9301, 1, 9, 'first, later sentence');
  48. $p1a = makeSentence($channel, 9301, 1, 2, 'first');
  49. $outside = makeSentence($channel, 9301, 4, 1, 'outside the chapter');
  50. $c = makeTextDiscussion($p3->uid, ['title' => 'c']);
  51. $b = makeTextDiscussion($p1b->uid, ['title' => 'b']);
  52. $a = makeTextDiscussion($p1a->uid, ['title' => 'a', 'type' => 'glossary']);
  53. makeTextDiscussion($outside->uid);
  54. makeTextDiscussion($p1a->uid, ['parent' => $a->id]); // 回复不列
  55. $json = $this->getJson(tipitakaDiscussionsUrl($channel, 'book=9301&chapter=1'))
  56. ->assertOk()
  57. ->json();
  58. expect(array_column($json['data'], 'id'))->toBe([$a->id, $b->id, $c->id])
  59. ->and($json['data'][0]['sentence'])->toBe(['book' => 9301, 'paragraph' => 1, 'word_start' => 2, 'word_end' => 2])
  60. ->and($json['data'][0])->toHaveKeys(['id', 'type', 'title', 'status', 'editor', 'abilities', 'reply_count', 'pos_start'])
  61. ->and($json['data'][0]['reply_count'])->toBe(1)
  62. ->and($json['meta']['total'])->toBe(3);
  63. });
  64. it('narrows by paragraph range and by the whole book or channel', function () {
  65. $channel = makeChannel(makeStudio('owner'));
  66. foreach ([1, 2, 3] as $para) {
  67. makeTextDiscussion(makeSentence($channel, 9302, $para, 1, "p{$para}")->uid, ['title' => "p{$para}"]);
  68. }
  69. makeTextDiscussion(makeSentence($channel, 9303, 1, 1, 'other book')->uid, ['title' => 'other book']);
  70. $titles = fn (string $query) => array_column($this->getJson(tipitakaDiscussionsUrl($channel, $query))->assertOk()->json('data'), 'title');
  71. expect($titles('book=9302&para=2&to=3'))->toBe(['p2', 'p3'])
  72. ->and($titles('book=9302&para=2'))->toBe(['p2'])
  73. ->and($titles('book=9302'))->toBe(['p1', 'p2', 'p3'])
  74. ->and($titles(''))->toBe(['p1', 'p2', 'p3', 'other book']);
  75. });
  76. it('only lists topics on sentences of this channel', function () {
  77. $owner = makeStudio('owner');
  78. $mine = makeChannel($owner, 'mine');
  79. $other = makeChannel($owner, 'other');
  80. makeTextDiscussion(makeSentence($mine, 9304, 1, 1, 'mine')->uid, ['title' => 'mine']);
  81. makeTextDiscussion(makeSentence($other, 9304, 1, 1, 'other')->uid, ['title' => 'other']);
  82. $this->getJson(tipitakaDiscussionsUrl($mine, 'book=9304'))
  83. ->assertOk()
  84. ->assertJsonCount(1, 'data')
  85. ->assertJsonPath('data.0.title', 'mine');
  86. $this->getJson(tipitakaDiscussionsUrl((string) Str::uuid()))->assertOk()->assertJsonCount(0, 'data');
  87. });
  88. it('lists only threads by default, and filters by several types, status and severity', function () {
  89. $owner = makeStudio('owner');
  90. $channel = makeChannel($owner);
  91. $sent = makeSentence($channel, 9305, 1, 1, 'text')->uid;
  92. makeTextDiscussion($sent, ['type' => 'issue', 'severity' => 'error', 'title' => 'issue-error']);
  93. makeTextDiscussion($sent, ['type' => 'issue', 'severity' => 'warning', 'status' => 'close', 'title' => 'issue-closed']);
  94. makeTextDiscussion($sent, ['type' => 'glossary', 'title' => 'glossary']);
  95. makeTextDiscussion($sent, ['type' => 'discussion', 'title' => 'discussion']);
  96. makeTextDiscussion($sent, ['type' => 'note', 'title' => 'note']);
  97. $titles = fn (string $query) => array_column(
  98. $this->getJson(tipitakaDiscussionsUrl($channel, "book=9305&{$query}"), authHeader($owner))->assertOk()->json('data'), 'title');
  99. expect($titles(''))->toEqualCanonicalizing(['issue-error', 'issue-closed', 'glossary', 'discussion']) // 批注不在默认里
  100. ->and($titles('type=issue,glossary'))->toEqualCanonicalizing(['issue-error', 'issue-closed', 'glossary'])
  101. ->and($titles('type=note'))->toBe(['note'])
  102. ->and($titles('type=issue&status=active'))->toBe(['issue-error'])
  103. ->and($titles('type=issue&severity=warning'))->toBe(['issue-closed']);
  104. });
  105. it('keeps tombstones of deleted topics that still have replies', function () {
  106. $channel = makeChannel(makeStudio('owner'));
  107. $sent = makeSentence($channel, 9306, 1, 1, 'text')->uid;
  108. $withReply = makeTextDiscussion($sent, ['title' => 'tombstone']);
  109. makeTextDiscussion($sent, ['parent' => $withReply->id]);
  110. $withReply->delete();
  111. makeTextDiscussion($sent, ['title' => 'gone'])->delete();
  112. $this->getJson(tipitakaDiscussionsUrl($channel, 'book=9306'))
  113. ->assertOk()
  114. ->assertJsonCount(1, 'data')
  115. ->assertJsonPath('data.0.id', $withReply->id)
  116. ->assertJsonPath('data.0.title', null);
  117. });
  118. it('applies the same visibility as the per-target list', function () {
  119. $owner = makeStudio('owner');
  120. $channel = makeChannel($owner);
  121. $sent = makeSentence($channel, 9307, 1, 1, 'text')->uid;
  122. $basic = makeStudio('basic');
  123. UserInfo::where('userid', $basic)->update(['role' => json_encode(['basic'])]);
  124. makeTextDiscussion($sent, ['type' => 'discussion', 'title' => 'private-others']);
  125. makeTextDiscussion($sent, ['type' => 'discussion', 'title' => 'private-mine', 'editor_uid' => $basic]);
  126. makeTextDiscussion($sent, ['type' => 'issue', 'title' => 'issue']);
  127. $titles = fn (array $headers) => array_column(
  128. $this->getJson(tipitakaDiscussionsUrl($channel, 'book=9307'), $headers)->assertOk()->json('data'), 'title');
  129. expect($titles([]))->toBe(['issue']) // 匿名:看不到 discussion
  130. ->and($titles(authHeader($basic)))->toEqualCanonicalizing(['private-mine', 'issue']) // basic:只看得到自己的 discussion
  131. ->and($titles(authHeader($owner)))->toHaveCount(3); // channel 编辑者:全部
  132. });
  133. it('rejects invalid parameters', function () {
  134. makeDiscussionChapter();
  135. $channel = makeChannel(makeStudio('owner'));
  136. $get = fn (string $query) => $this->getJson(tipitakaDiscussionsUrl($channel, $query));
  137. $get('chapter=1')->assertStatus(422)->assertJsonValidationErrors('book');
  138. $get('book=9301&chapter=1&para=2')->assertStatus(422)->assertJsonValidationErrors('chapter');
  139. $get('book=9301&para=3&to=2')->assertStatus(422)->assertJsonValidationErrors('to');
  140. $get('type=issue,bogus')->assertStatus(422)->assertJsonValidationErrors('type');
  141. $get('per_page=500')->assertStatus(422)->assertJsonValidationErrors('per_page');
  142. $get('book=9301&chapter=99')->assertNotFound(); // 没有这一段(同 tipitaka-reading)
  143. $this->getJson('/api/v3/tipitaka-discussions/not-a-uuid')->assertNotFound();
  144. });
  145. it('does not run a query per row', function () {
  146. $channel = makeChannel(makeStudio('owner'));
  147. $countQueriesFor = function (int $rows) use ($channel) {
  148. $book = 9400 + $rows;
  149. foreach (range(1, $rows) as $i) {
  150. makeTextDiscussion(makeSentence($channel, $book, $i, 1, "s{$i}")->uid, ['editor_uid' => makeStudio("n-{$book}-{$i}")]);
  151. }
  152. $this->getJson(tipitakaDiscussionsUrl($channel, "book={$book}"))->assertOk(); // 预热 UserService 等缓存
  153. DB::flushQueryLog();
  154. DB::enableQueryLog();
  155. $this->getJson(tipitakaDiscussionsUrl($channel, "book={$book}"))->assertOk()->assertJsonCount($rows, 'data');
  156. $count = count(DB::getQueryLog());
  157. DB::disableQueryLog();
  158. return $count;
  159. };
  160. expect($countQueriesFor(12))->toBe($countQueriesFor(2));
  161. });