TipitakaChapterChannelV3Test.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. <?php
  2. use App\Facades\RedisCache;
  3. use App\Models\Channel;
  4. use App\Models\GroupMember;
  5. use App\Models\ProgressChapter;
  6. use App\Models\Share;
  7. use App\Models\View;
  8. use Illuminate\Foundation\Testing\RefreshDatabase;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Str;
  11. uses(RefreshDatabase::class);
  12. /**
  13. * GET /v3/tipitaka-chapter-channels/{book}/{para} —— 一章有哪些 channel 及完成度。
  14. *
  15. * 这里断言的是形状而非契约(spectator 未安装,见 v3-resource skill 第 3 步)。
  16. */
  17. function makeChapterChannelRow(string $channelUid, float $progress, int $book = 9101, int $para = 5): ProgressChapter
  18. {
  19. $row = new ProgressChapter;
  20. $row->forceFill([
  21. 'id' => random_int(1, PHP_INT_MAX),
  22. 'uid' => (string) Str::uuid(),
  23. 'book' => $book,
  24. 'para' => $para,
  25. 'lang' => 'zh-Hans',
  26. 'channel_id' => $channelUid,
  27. 'progress' => $progress,
  28. 'all_trans' => 0,
  29. 'public' => 0,
  30. ])->save();
  31. return $row;
  32. }
  33. function makePrivateChannel(string $ownerUid, string $name): string
  34. {
  35. $uid = makeChannel($ownerUid, $name);
  36. Channel::where('uid', $uid)->update(['status' => 10]);
  37. return $uid;
  38. }
  39. function shareChannelTo(string $channelUid, string $cooperatorUid): void
  40. {
  41. (new Share)->forceFill([
  42. 'id' => random_int(1, PHP_INT_MAX),
  43. 'res_id' => $channelUid,
  44. 'res_type' => 2,
  45. 'cooperator_id' => $cooperatorUid,
  46. 'cooperator_type' => 0,
  47. 'power' => 10,
  48. 'create_time' => time() * 1000,
  49. 'modify_time' => time() * 1000,
  50. ])->save();
  51. }
  52. it('lists channels of a chapter ordered by progress', function () {
  53. $owner = makeStudio('cc-owner');
  54. $low = makeChannel($owner, 'low');
  55. $high = makeChannel($owner, 'high');
  56. makeChapterChannelRow($low, 0.2);
  57. $top = makeChapterChannelRow($high, 0.9);
  58. makeChapterChannelRow($high, 0.5, 9101, 6); // 别的章节
  59. View::forceCreate(['id' => (string) Str::uuid(), 'target_id' => $top->uid, 'target_type' => 'chapter', 'user_ip' => '127.0.0.1']);
  60. View::forceCreate(['id' => (string) Str::uuid(), 'target_id' => $top->uid, 'target_type' => 'chapter', 'user_ip' => '127.0.0.1']);
  61. RedisCache::put("/chapter_dynamic/9101/5/ch_{$high}", [0, 3, 1]);
  62. $json = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5')->assertOk()->json();
  63. expect($json['data'])->toHaveCount(2)
  64. ->and($json['meta']['total'])->toBe(2)
  65. ->and($json['data'][0])->toHaveKeys(['id', 'book', 'para', 'progress', 'progress_line', 'views', 'updated_at', 'channel', 'studio'])
  66. ->and($json['data'][0]['channel'])->toMatchArray(['id' => $high, 'name' => 'high', 'type' => 'translation'])
  67. ->and($json['data'][0]['progress'])->toBe(0.9)
  68. ->and($json['data'][0]['views'])->toBe(2)
  69. ->and($json['data'][0]['progress_line'])->toBe([0, 3, 1])
  70. ->and($json['data'][0]['studio'])->toMatchArray(['id' => $owner, 'nickName' => 'cc-owner'])
  71. ->and($json['data'][1]['channel']['id'])->toBe($low)
  72. ->and($json['data'][1]['progress_line'])->toBeNull();
  73. });
  74. it('hides private channels from anonymous and other users', function () {
  75. $owner = makeStudio('cc-private-owner');
  76. makeChapterChannelRow(makeChannel($owner, 'public'), 0.5);
  77. makeChapterChannelRow(makePrivateChannel($owner, 'secret'), 0.9);
  78. $anonymous = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5')->assertOk()->json('data');
  79. $stranger = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5', authHeader(makeStudio('cc-stranger')))
  80. ->assertOk()->json('data');
  81. expect(array_column(array_column($anonymous, 'channel'), 'name'))->toBe(['public'])
  82. ->and(array_column(array_column($stranger, 'channel'), 'name'))->toBe(['public']);
  83. });
  84. it('shows private channels to the owner and to users they are shared with', function () {
  85. $owner = makeStudio('cc-share-owner');
  86. $direct = makeStudio('cc-share-direct');
  87. $viaGroup = makeStudio('cc-share-group');
  88. $secret = makePrivateChannel($owner, 'secret');
  89. makeChapterChannelRow($secret, 0.9);
  90. shareChannelTo($secret, $direct);
  91. $groupUid = (string) Str::uuid();
  92. (new GroupMember)->forceFill([
  93. 'id' => random_int(1, PHP_INT_MAX),
  94. 'user_id' => $viaGroup,
  95. 'group_id' => $groupUid,
  96. 'power' => 10,
  97. 'level' => 0,
  98. 'status' => 0,
  99. ])->save();
  100. shareChannelTo($secret, $groupUid);
  101. foreach ([$owner, $direct, $viaGroup] as $viewer) {
  102. expect($this->getJson('/api/v3/tipitaka-chapter-channels/9101/5', authHeader($viewer))
  103. ->assertOk()->json('data.0.channel.id'))->toBe($secret);
  104. }
  105. });
  106. it('returns an empty list for a chapter without channels', function () {
  107. $this->getJson('/api/v3/tipitaka-chapter-channels/9101/999')
  108. ->assertOk()
  109. ->assertJsonPath('data', [])
  110. ->assertJsonPath('meta.total', 0);
  111. });
  112. it('rejects a non-numeric path and an oversized page', function () {
  113. // 路径参数格式不对匹配不上路由 → 404;查询参数不对 → 422
  114. $this->getJson('/api/v3/tipitaka-chapter-channels/abc/5')->assertNotFound();
  115. $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5?per_page=500')
  116. ->assertStatus(422)
  117. ->assertJsonStructure(['errors' => ['per_page']]);
  118. });
  119. it('keeps the query count independent of the row count', function () {
  120. $owner = makeStudio('cc-n1');
  121. $countQueriesFor = function (int $rows, int $para) use ($owner): int {
  122. foreach (range(1, $rows) as $i) {
  123. makeChapterChannelRow(makeChannel(makeStudio("cc-n1-{$para}-{$i}"), "c{$i}"), $i / 100, 9102, $para);
  124. }
  125. DB::flushQueryLog();
  126. DB::enableQueryLog();
  127. $this->getJson("/api/v3/tipitaka-chapter-channels/9102/{$para}", authHeader($owner))->assertOk();
  128. $count = count(DB::getQueryLog());
  129. DB::disableQueryLog();
  130. return $count;
  131. };
  132. expect($countQueriesFor(12, 1))->toBe($countQueriesFor(2, 2));
  133. });