| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- <?php
- use App\Models\Reaction;
- use Illuminate\Foundation\Testing\RefreshDatabase;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Str;
- uses(RefreshDatabase::class);
- /**
- * v3 reactions:点赞/收藏/书签/关注/下载记录共用一张 likes 表。
- *
- * 端点:
- * GET /api/v3/reactions?target_id=… 公共,按 target 列记录
- * GET /api/v3/reactions/tally?target_id=… 公共,各 type 计数 + 可选我的选择
- * GET /api/v3/me/reactions 我的列表(登录)
- * POST /api/v3/me/reactions 我添加(登录,幂等)
- * DELETE /api/v3/me/reactions/{reaction} 我删除(登录,只能删自己的)
- */
- function makeReaction(array $attrs = []): Reaction
- {
- return Reaction::forceCreate(array_merge([
- 'id' => (string) Str::uuid(),
- 'type' => 'like',
- 'target_id' => (string) Str::uuid(),
- 'target_type' => 'task',
- 'user_id' => makeStudio('reaction-'.Str::random(6)),
- 'context' => null,
- ], $attrs));
- }
- it('lists reactions of a target without auth', function () {
- $target = (string) Str::uuid();
- makeReaction(['target_id' => $target]);
- makeReaction(['target_id' => $target, 'type' => 'watch']);
- makeReaction(['target_id' => (string) Str::uuid()]);
- $data = $this->getJson("/api/v3/reactions?target_id={$target}")
- ->assertOk()
- ->json('data');
- expect($data)->toHaveCount(2)
- ->each(fn ($row) => $row->toHaveKeys(['id', 'type', 'target_id', 'target_type', 'user']));
- });
- it('filters reactions by type', function () {
- $target = (string) Str::uuid();
- makeReaction(['target_id' => $target, 'type' => 'like']);
- makeReaction(['target_id' => $target, 'type' => 'favorite']);
- $data = $this->getJson("/api/v3/reactions?target_id={$target}&type=like")
- ->assertOk()
- ->json('data');
- expect($data)->toHaveCount(1)
- ->and($data[0]['type'])->toBe('like');
- });
- it('rejects a non-uuid target_id', function () {
- $this->getJson('/api/v3/reactions?target_id=not-a-uuid')->assertStatus(422);
- });
- it('rejects an out-of-range per_page', function () {
- $target = (string) Str::uuid();
- $this->getJson("/api/v3/reactions?target_id={$target}&per_page=100000")->assertStatus(422);
- $this->getJson("/api/v3/reactions?target_id={$target}&per_page=0")->assertStatus(422);
- });
- it('honours per_page', function () {
- $target = (string) Str::uuid();
- makeReaction(['target_id' => $target, 'type' => 'like']);
- makeReaction(['target_id' => $target, 'type' => 'watch']);
- $json = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=1")
- ->assertOk()
- ->json();
- expect($json['data'])->toHaveCount(1)
- ->and($json['meta']['per_page'])->toBe(1)
- ->and($json['meta']['total'])->toBe(2);
- });
- it('embeds the acting user without querying per row', function () {
- // N+1 守卫:查询数必须与行数无关。
- // 现在是 4 条(分页 count + 取页 + 预加载 user_infos + 预加载 ai_models);
- // 断言的是「2 行与 12 行一样多」而不是硬编码 4——前者是真正的不变量,
- // 后者会被 Laravel 内部实现的变动误伤。Resource 里每行再查一次就立刻红。
- $countQueriesFor = function (int $rows): int {
- $target = (string) Str::uuid();
- foreach (range(1, $rows) as $i) {
- makeReaction([
- 'target_id' => $target,
- 'type' => 'like',
- 'user_id' => makeStudio('actor-'.Str::random(8)),
- ]);
- }
- DB::enableQueryLog();
- DB::flushQueryLog(); // enable 不清空历史,两次测量之间必须手动冲掉
- $data = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=200")
- ->assertOk()
- ->json('data');
- $queries = count(DB::getQueryLog());
- DB::disableQueryLog();
- expect($data)->toHaveCount($rows);
- return $queries;
- };
- expect($countQueriesFor(12))->toBe($countQueriesFor(2));
- });
- it('resolves the acting user through the constrained eager load', function () {
- $target = (string) Str::uuid();
- $uid = makeStudio('actor-name');
- makeReaction(['target_id' => $target, 'user_id' => $uid]);
- $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
- // 受限列漏掉哪个,这里就会露馅:id 来自 userid、nickName 来自 nickname、sn 来自 id
- expect($data[0]['user']['id'])->toBe($uid)
- ->and($data[0]['user']['nickName'])->toBe('actor-name')
- ->and($data[0]['user']['userName'])->toBe('actor-name')
- ->and($data[0]['user']['sn'])->toBeGreaterThan(0);
- });
- it('falls back to the unknown placeholder for an orphan user_id', function () {
- $target = (string) Str::uuid();
- Reaction::forceCreate([
- 'id' => (string) Str::uuid(),
- 'type' => 'like',
- 'target_id' => $target,
- 'target_type' => 'task',
- 'user_id' => (string) Str::uuid(), // 既不在 user_infos 也不在 ai_models
- 'context' => null,
- ]);
- $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
- expect($data[0]['user']['nickName'])->toBe('unknown');
- });
- it('tallies counts per type without auth', function () {
- $target = (string) Str::uuid();
- makeReaction(['target_id' => $target, 'type' => 'like']);
- makeReaction(['target_id' => $target, 'type' => 'like']);
- makeReaction(['target_id' => $target, 'type' => 'favorite']);
- $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}")
- ->assertOk()
- ->json('data');
- $like = collect($data)->firstWhere('type', 'like');
- $favorite = collect($data)->firstWhere('type', 'favorite');
- expect($like['count'])->toBe(2)
- ->and($like['selected'])->toBeFalse()
- ->and($favorite['count'])->toBe(1);
- });
- it('marks my selection in the tally when authenticated', function () {
- $target = (string) Str::uuid();
- $uid = makeStudio('tally-owner');
- $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
- makeReaction(['target_id' => $target, 'type' => 'like']);
- $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}", authHeader($uid))
- ->assertOk()
- ->json('data');
- $like = collect($data)->firstWhere('type', 'like');
- expect($like['selected'])->toBeTrue()
- ->and($like['id'])->toBe($mine->id);
- });
- it('requires auth to list my reactions', function () {
- $this->getJson('/api/v3/me/reactions')->assertStatus(401);
- });
- it('lists only my reactions', function () {
- $uid = makeStudio('me-owner');
- makeReaction(['user_id' => $uid, 'type' => 'like']);
- makeReaction(['user_id' => $uid, 'type' => 'favorite']);
- makeReaction(['user_id' => makeStudio('other'), 'type' => 'like']);
- $data = $this->getJson('/api/v3/me/reactions', authHeader($uid))
- ->assertOk()
- ->json('data');
- expect($data)->toHaveCount(2);
- });
- it('requires auth to add a reaction', function () {
- $this->postJson('/api/v3/me/reactions', [
- 'type' => 'like',
- 'target_id' => (string) Str::uuid(),
- 'target_type' => 'task',
- ])->assertStatus(401);
- });
- it('answers 401 before 422 when not logged in', function () {
- // auth.v3 中间件在 FormRequest 之前跑,所以「未登录 + 参数非法」是 401 不是 422。
- // 这正是把登录闸放进中间件(而不是控制器第一行)买到的东西
- $this->postJson('/api/v3/me/reactions', ['type' => 'bogus'])
- ->assertStatus(401)
- ->assertJsonStructure(['type', 'title', 'status']);
- });
- it('does not accept the v1 cookie bypass', function () {
- $uid = makeStudio('cookie-user');
- // 必须直接改 $_COOKIE 超全局:withUnencryptedCookies() 只填 Symfony Request 的
- // cookie 袋子,不碰超全局,用它写出来的 cookie 测试是空转的(不管代码里有没有
- // 那条分支都会过)。helper 见 AuthServiceCookieTest.php
- $backup = $_COOKIE;
- $_COOKIE = ['user_uid' => $uid, 'user_id' => '1'];
- try {
- $this->getJson('/api/v3/me/reactions')->assertStatus(401);
- } finally {
- $_COOKIE = $backup;
- }
- });
- it('validates required fields when adding', function () {
- $uid = makeStudio('store-owner');
- $this->postJson('/api/v3/me/reactions', ['type' => 'like'], authHeader($uid))
- ->assertStatus(422);
- });
- it('rejects an unknown target_type when adding', function () {
- $uid = makeStudio('store-owner');
- $this->postJson('/api/v3/me/reactions', [
- 'type' => 'like',
- 'target_id' => (string) Str::uuid(),
- 'target_type' => 'unknown',
- ], authHeader($uid))->assertStatus(422);
- });
- it('adds a reaction and forces user_id to the current user', function () {
- $uid = makeStudio('store-owner');
- $target = (string) Str::uuid();
- $data = $this->postJson('/api/v3/me/reactions', [
- 'type' => 'like',
- 'target_id' => $target,
- 'target_type' => 'task',
- 'user_id' => (string) Str::uuid(), // 客户端塞的 user_id 应被忽略
- ], authHeader($uid))->assertCreated()->json('data');
- expect($data['selected'])->toBeTrue()
- ->and($data['count'])->toBe(1)
- ->and($data['id'])->not->toBeNull();
- $record = Reaction::find($data['id']);
- expect($record->user_id)->toBe($uid);
- });
- it('is idempotent: repeat add returns the same record', function () {
- $uid = makeStudio('store-owner');
- $target = (string) Str::uuid();
- $payload = ['type' => 'favorite', 'target_id' => $target, 'target_type' => 'task'];
- // 首次为 201(新建),重复提交命中唯一键,为 200(幂等不新建)
- $first = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertCreated()->json('data');
- $second = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertOk()->json('data');
- expect($second['id'])->toBe($first['id'])
- ->and($second['count'])->toBe(1)
- ->and(Reaction::count())->toBe(1);
- });
- it('requires auth to delete a reaction', function () {
- $reaction = makeReaction();
- $this->deleteJson("/api/v3/me/reactions/{$reaction->id}")->assertStatus(401);
- });
- it('deletes my reaction and reports the remaining count', function () {
- $uid = makeStudio('del-owner');
- $target = (string) Str::uuid();
- $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
- makeReaction(['target_id' => $target, 'type' => 'like']);
- $data = $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
- ->assertOk()
- ->json('data');
- expect($data['selected'])->toBeFalse()
- ->and($data['count'])->toBe(1)
- ->and(Reaction::find($mine->id))->toBeNull();
- });
- it('forbids deleting another user reaction', function () {
- $other = makeReaction(['user_id' => makeStudio('del-other')]);
- $detail = $this->deleteJson("/api/v3/me/reactions/{$other->id}", [], authHeader(makeStudio('del-me')))
- ->assertStatus(403)
- ->json('detail');
- // 翻译文件缺键时 __() 会原样吐出键名,这条守住那种退化
- expect($detail)->not->toBe('site.forbidden')->not->toBeEmpty();
- expect(Reaction::find($other->id))->not->toBeNull();
- });
- it('rejects an unknown type filter on my reactions', function () {
- $this->getJson('/api/v3/me/reactions?type=bogus', authHeader(makeStudio('filter-owner')))
- ->assertStatus(422);
- });
|