ReactionV3Test.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. <?php
  2. use App\Models\Reaction;
  3. use Illuminate\Foundation\Testing\RefreshDatabase;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Str;
  6. uses(RefreshDatabase::class);
  7. /**
  8. * v3 reactions:点赞/收藏/书签/关注/下载记录共用一张 likes 表。
  9. *
  10. * 端点:
  11. * GET /api/v3/reactions?target_id=… 公共,按 target 列记录
  12. * GET /api/v3/reactions/tally?target_id=… 公共,各 type 计数 + 可选我的选择
  13. * GET /api/v3/me/reactions 我的列表(登录)
  14. * POST /api/v3/me/reactions 我添加(登录,幂等)
  15. * DELETE /api/v3/me/reactions/{reaction} 我删除(登录,只能删自己的)
  16. */
  17. function makeReaction(array $attrs = []): Reaction
  18. {
  19. return Reaction::forceCreate(array_merge([
  20. 'id' => (string) Str::uuid(),
  21. 'type' => 'like',
  22. 'target_id' => (string) Str::uuid(),
  23. 'target_type' => 'task',
  24. 'user_id' => makeStudio('reaction-'.Str::random(6)),
  25. 'context' => null,
  26. ], $attrs));
  27. }
  28. it('lists reactions of a target without auth', function () {
  29. $target = (string) Str::uuid();
  30. makeReaction(['target_id' => $target]);
  31. makeReaction(['target_id' => $target, 'type' => 'watch']);
  32. makeReaction(['target_id' => (string) Str::uuid()]);
  33. $data = $this->getJson("/api/v3/reactions?target_id={$target}")
  34. ->assertOk()
  35. ->json('data');
  36. expect($data)->toHaveCount(2)
  37. ->each(fn ($row) => $row->toHaveKeys(['id', 'type', 'target_id', 'target_type', 'user']));
  38. });
  39. it('filters reactions by type', function () {
  40. $target = (string) Str::uuid();
  41. makeReaction(['target_id' => $target, 'type' => 'like']);
  42. makeReaction(['target_id' => $target, 'type' => 'favorite']);
  43. $data = $this->getJson("/api/v3/reactions?target_id={$target}&type=like")
  44. ->assertOk()
  45. ->json('data');
  46. expect($data)->toHaveCount(1)
  47. ->and($data[0]['type'])->toBe('like');
  48. });
  49. it('rejects a non-uuid target_id', function () {
  50. $this->getJson('/api/v3/reactions?target_id=not-a-uuid')->assertStatus(422);
  51. });
  52. it('rejects an out-of-range per_page', function () {
  53. $target = (string) Str::uuid();
  54. $this->getJson("/api/v3/reactions?target_id={$target}&per_page=100000")->assertStatus(422);
  55. $this->getJson("/api/v3/reactions?target_id={$target}&per_page=0")->assertStatus(422);
  56. });
  57. it('honours per_page', function () {
  58. $target = (string) Str::uuid();
  59. makeReaction(['target_id' => $target, 'type' => 'like']);
  60. makeReaction(['target_id' => $target, 'type' => 'watch']);
  61. $json = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=1")
  62. ->assertOk()
  63. ->json();
  64. expect($json['data'])->toHaveCount(1)
  65. ->and($json['meta']['per_page'])->toBe(1)
  66. ->and($json['meta']['total'])->toBe(2);
  67. });
  68. it('embeds the acting user without querying per row', function () {
  69. // N+1 守卫:查询数必须与行数无关。
  70. // 现在是 4 条(分页 count + 取页 + 预加载 user_infos + 预加载 ai_models);
  71. // 断言的是「2 行与 12 行一样多」而不是硬编码 4——前者是真正的不变量,
  72. // 后者会被 Laravel 内部实现的变动误伤。Resource 里每行再查一次就立刻红。
  73. $countQueriesFor = function (int $rows): int {
  74. $target = (string) Str::uuid();
  75. foreach (range(1, $rows) as $i) {
  76. makeReaction([
  77. 'target_id' => $target,
  78. 'type' => 'like',
  79. 'user_id' => makeStudio('actor-'.Str::random(8)),
  80. ]);
  81. }
  82. DB::enableQueryLog();
  83. DB::flushQueryLog(); // enable 不清空历史,两次测量之间必须手动冲掉
  84. $data = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=200")
  85. ->assertOk()
  86. ->json('data');
  87. $queries = count(DB::getQueryLog());
  88. DB::disableQueryLog();
  89. expect($data)->toHaveCount($rows);
  90. return $queries;
  91. };
  92. expect($countQueriesFor(12))->toBe($countQueriesFor(2));
  93. });
  94. it('resolves the acting user through the constrained eager load', function () {
  95. $target = (string) Str::uuid();
  96. $uid = makeStudio('actor-name');
  97. makeReaction(['target_id' => $target, 'user_id' => $uid]);
  98. $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
  99. // 受限列漏掉哪个,这里就会露馅:id 来自 userid、nickName 来自 nickname、sn 来自 id
  100. expect($data[0]['user']['id'])->toBe($uid)
  101. ->and($data[0]['user']['nickName'])->toBe('actor-name')
  102. ->and($data[0]['user']['userName'])->toBe('actor-name')
  103. ->and($data[0]['user']['sn'])->toBeGreaterThan(0);
  104. });
  105. it('falls back to the unknown placeholder for an orphan user_id', function () {
  106. $target = (string) Str::uuid();
  107. Reaction::forceCreate([
  108. 'id' => (string) Str::uuid(),
  109. 'type' => 'like',
  110. 'target_id' => $target,
  111. 'target_type' => 'task',
  112. 'user_id' => (string) Str::uuid(), // 既不在 user_infos 也不在 ai_models
  113. 'context' => null,
  114. ]);
  115. $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
  116. expect($data[0]['user']['nickName'])->toBe('unknown');
  117. });
  118. it('tallies counts per type without auth', function () {
  119. $target = (string) Str::uuid();
  120. makeReaction(['target_id' => $target, 'type' => 'like']);
  121. makeReaction(['target_id' => $target, 'type' => 'like']);
  122. makeReaction(['target_id' => $target, 'type' => 'favorite']);
  123. $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}")
  124. ->assertOk()
  125. ->json('data');
  126. $like = collect($data)->firstWhere('type', 'like');
  127. $favorite = collect($data)->firstWhere('type', 'favorite');
  128. expect($like['count'])->toBe(2)
  129. ->and($like['selected'])->toBeFalse()
  130. ->and($favorite['count'])->toBe(1);
  131. });
  132. it('marks my selection in the tally when authenticated', function () {
  133. $target = (string) Str::uuid();
  134. $uid = makeStudio('tally-owner');
  135. $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
  136. makeReaction(['target_id' => $target, 'type' => 'like']);
  137. $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}", authHeader($uid))
  138. ->assertOk()
  139. ->json('data');
  140. $like = collect($data)->firstWhere('type', 'like');
  141. expect($like['selected'])->toBeTrue()
  142. ->and($like['id'])->toBe($mine->id);
  143. });
  144. it('requires auth to list my reactions', function () {
  145. $this->getJson('/api/v3/me/reactions')->assertStatus(401);
  146. });
  147. it('lists only my reactions', function () {
  148. $uid = makeStudio('me-owner');
  149. makeReaction(['user_id' => $uid, 'type' => 'like']);
  150. makeReaction(['user_id' => $uid, 'type' => 'favorite']);
  151. makeReaction(['user_id' => makeStudio('other'), 'type' => 'like']);
  152. $data = $this->getJson('/api/v3/me/reactions', authHeader($uid))
  153. ->assertOk()
  154. ->json('data');
  155. expect($data)->toHaveCount(2);
  156. });
  157. it('requires auth to add a reaction', function () {
  158. $this->postJson('/api/v3/me/reactions', [
  159. 'type' => 'like',
  160. 'target_id' => (string) Str::uuid(),
  161. 'target_type' => 'task',
  162. ])->assertStatus(401);
  163. });
  164. it('answers 401 before 422 when not logged in', function () {
  165. // auth.v3 中间件在 FormRequest 之前跑,所以「未登录 + 参数非法」是 401 不是 422。
  166. // 这正是把登录闸放进中间件(而不是控制器第一行)买到的东西
  167. $this->postJson('/api/v3/me/reactions', ['type' => 'bogus'])
  168. ->assertStatus(401)
  169. ->assertJsonStructure(['type', 'title', 'status']);
  170. });
  171. it('does not accept the v1 cookie bypass', function () {
  172. $uid = makeStudio('cookie-user');
  173. // 必须直接改 $_COOKIE 超全局:withUnencryptedCookies() 只填 Symfony Request 的
  174. // cookie 袋子,不碰超全局,用它写出来的 cookie 测试是空转的(不管代码里有没有
  175. // 那条分支都会过)。helper 见 AuthServiceCookieTest.php
  176. $backup = $_COOKIE;
  177. $_COOKIE = ['user_uid' => $uid, 'user_id' => '1'];
  178. try {
  179. $this->getJson('/api/v3/me/reactions')->assertStatus(401);
  180. } finally {
  181. $_COOKIE = $backup;
  182. }
  183. });
  184. it('validates required fields when adding', function () {
  185. $uid = makeStudio('store-owner');
  186. $this->postJson('/api/v3/me/reactions', ['type' => 'like'], authHeader($uid))
  187. ->assertStatus(422);
  188. });
  189. it('rejects an unknown target_type when adding', function () {
  190. $uid = makeStudio('store-owner');
  191. $this->postJson('/api/v3/me/reactions', [
  192. 'type' => 'like',
  193. 'target_id' => (string) Str::uuid(),
  194. 'target_type' => 'unknown',
  195. ], authHeader($uid))->assertStatus(422);
  196. });
  197. it('adds a reaction and forces user_id to the current user', function () {
  198. $uid = makeStudio('store-owner');
  199. $target = (string) Str::uuid();
  200. $data = $this->postJson('/api/v3/me/reactions', [
  201. 'type' => 'like',
  202. 'target_id' => $target,
  203. 'target_type' => 'task',
  204. 'user_id' => (string) Str::uuid(), // 客户端塞的 user_id 应被忽略
  205. ], authHeader($uid))->assertCreated()->json('data');
  206. expect($data['selected'])->toBeTrue()
  207. ->and($data['count'])->toBe(1)
  208. ->and($data['id'])->not->toBeNull();
  209. $record = Reaction::find($data['id']);
  210. expect($record->user_id)->toBe($uid);
  211. });
  212. it('is idempotent: repeat add returns the same record', function () {
  213. $uid = makeStudio('store-owner');
  214. $target = (string) Str::uuid();
  215. $payload = ['type' => 'favorite', 'target_id' => $target, 'target_type' => 'task'];
  216. // 首次为 201(新建),重复提交命中唯一键,为 200(幂等不新建)
  217. $first = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertCreated()->json('data');
  218. $second = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertOk()->json('data');
  219. expect($second['id'])->toBe($first['id'])
  220. ->and($second['count'])->toBe(1)
  221. ->and(Reaction::count())->toBe(1);
  222. });
  223. it('requires auth to delete a reaction', function () {
  224. $reaction = makeReaction();
  225. $this->deleteJson("/api/v3/me/reactions/{$reaction->id}")->assertStatus(401);
  226. });
  227. it('deletes my reaction and reports the remaining count', function () {
  228. $uid = makeStudio('del-owner');
  229. $target = (string) Str::uuid();
  230. $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
  231. makeReaction(['target_id' => $target, 'type' => 'like']);
  232. $data = $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
  233. ->assertOk()
  234. ->json('data');
  235. expect($data['selected'])->toBeFalse()
  236. ->and($data['count'])->toBe(1)
  237. ->and(Reaction::find($mine->id))->toBeNull();
  238. });
  239. it('forbids deleting another user reaction', function () {
  240. $other = makeReaction(['user_id' => makeStudio('del-other')]);
  241. $detail = $this->deleteJson("/api/v3/me/reactions/{$other->id}", [], authHeader(makeStudio('del-me')))
  242. ->assertStatus(403)
  243. ->json('detail');
  244. // 翻译文件缺键时 __() 会原样吐出键名,这条守住那种退化
  245. expect($detail)->not->toBe('site.forbidden')->not->toBeEmpty();
  246. expect(Reaction::find($other->id))->not->toBeNull();
  247. });
  248. it('rejects an unknown type filter on my reactions', function () {
  249. $this->getJson('/api/v3/me/reactions?type=bogus', authHeader(makeStudio('filter-owner')))
  250. ->assertStatus(422);
  251. });