UserServiceTest.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. <?php
  2. use App\Http\Api\AiAssistantApi;
  3. use App\Http\Api\UserApi;
  4. use App\Models\AiModel;
  5. use App\Models\UserInfo;
  6. use App\Services\UserService;
  7. use Illuminate\Foundation\Testing\RefreshDatabase;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Str;
  10. uses(RefreshDatabase::class);
  11. /**
  12. * UserService 取代 UserApi / AiAssistantApi 两个静态类。
  13. *
  14. * 这里断言两件事:
  15. * 1. 新实现的行为(批量、身份映射、占位口径)
  16. * 2. 转发壳与 Service 返回同一结果——v2 的六十多个调用点靠这条兜底
  17. */
  18. function makeAssistant(string $name, array $attrs = []): AiModel
  19. {
  20. $assistant = new AiModel;
  21. $assistant->forceFill(array_merge([
  22. 'id' => random_int(1, PHP_INT_MAX),
  23. 'uid' => (string) Str::uuid(),
  24. 'name' => $name,
  25. 'real_name' => $name.'-real',
  26. 'model' => 'gpt-4',
  27. 'url' => null,
  28. 'avatar' => null,
  29. // ai_models 的 owner_id / editor_id 是 NOT NULL 且无默认值
  30. 'owner_id' => (string) Str::uuid(),
  31. 'editor_id' => (string) Str::uuid(),
  32. ], $attrs))->save();
  33. return $assistant;
  34. }
  35. it('resolves a human user', function () {
  36. $uid = makeStudio('alice');
  37. $profile = app(UserService::class)->byUuid($uid);
  38. expect($profile['id'])->toBe($uid)
  39. ->and($profile['nickName'])->toBe('alice')
  40. ->and($profile['userName'])->toBe('alice')
  41. ->and($profile['sn'])->toBeGreaterThan(0);
  42. });
  43. it('falls back to an ai assistant when the uuid is not a human', function () {
  44. $assistant = makeAssistant('claude');
  45. $profile = app(UserService::class)->byUuid($assistant->uid);
  46. expect($profile['id'])->toBe($assistant->uid)
  47. ->and($profile['nickName'])->toBe('claude')
  48. ->and($profile['roles'])->toBe(['ai'])
  49. ->and($profile['sn'])->toBe(0);
  50. });
  51. it('returns the unknown placeholder for an unresolvable uuid', function () {
  52. $profile = app(UserService::class)->byUuid((string) Str::uuid());
  53. expect($profile)->toBe([
  54. 'id' => 0,
  55. 'nickName' => 'unknown',
  56. 'userName' => 'unknown',
  57. 'realName' => 'unknown',
  58. 'avatar' => '',
  59. ]);
  60. });
  61. it('costs no query for a null uuid', function () {
  62. DB::enableQueryLog();
  63. DB::flushQueryLog();
  64. $profile = app(UserService::class)->byUuid(null);
  65. $queries = count(DB::getQueryLog());
  66. DB::disableQueryLog();
  67. // 旧实现会为 null 白查两张表
  68. expect($queries)->toBe(0)
  69. ->and($profile['nickName'])->toBe('unknown');
  70. });
  71. it('resolves a mixed batch in two queries regardless of size', function () {
  72. $humans = collect(range(1, 6))->map(fn ($i) => makeStudio("batch-user-{$i}"))->all();
  73. $assistants = collect(range(1, 3))->map(fn ($i) => makeAssistant("batch-ai-{$i}")->uid)->all();
  74. $orphan = (string) Str::uuid();
  75. DB::enableQueryLog();
  76. DB::flushQueryLog();
  77. $map = app(UserService::class)->byUuids([...$humans, ...$assistants, $orphan]);
  78. $queries = count(DB::getQueryLog());
  79. DB::disableQueryLog();
  80. // user_infos 一次 + ai_models 一次,与 uuid 个数无关
  81. expect($queries)->toBe(2)
  82. ->and($map)->toHaveCount(9) // 解析不到的 orphan 不出现在结果里
  83. ->and($map)->not->toHaveKey($orphan)
  84. ->and($map[$humans[0]]['nickName'])->toBe('batch-user-1')
  85. ->and($map[$assistants[0]]['roles'])->toBe(['ai']);
  86. });
  87. it('skips ai_models entirely when every uuid is a human', function () {
  88. $humans = collect(range(1, 4))->map(fn ($i) => makeStudio("human-only-{$i}"))->all();
  89. DB::enableQueryLog();
  90. DB::flushQueryLog();
  91. app(UserService::class)->byUuids($humans);
  92. $queries = count(DB::getQueryLog());
  93. DB::disableQueryLog();
  94. expect($queries)->toBe(1);
  95. });
  96. it('remembers resolved identities within the request', function () {
  97. $uid = makeStudio('memoized');
  98. $service = app(UserService::class);
  99. $service->byUuid($uid);
  100. DB::enableQueryLog();
  101. DB::flushQueryLog();
  102. $again = $service->byUuid($uid);
  103. $queries = count(DB::getQueryLog());
  104. DB::disableQueryLog();
  105. expect($queries)->toBe(0)
  106. ->and($again['nickName'])->toBe('memoized');
  107. });
  108. it('remembers negative results too', function () {
  109. $orphan = (string) Str::uuid();
  110. $service = app(UserService::class);
  111. $service->byUuid($orphan);
  112. DB::enableQueryLog();
  113. DB::flushQueryLog();
  114. $service->byUuid($orphan);
  115. $queries = count(DB::getQueryLog());
  116. DB::disableQueryLog();
  117. expect($queries)->toBe(0);
  118. });
  119. it('re-reads after flush', function () {
  120. $uid = makeStudio('flushed');
  121. $service = app(UserService::class);
  122. $service->byUuid($uid);
  123. $service->flush();
  124. DB::enableQueryLog();
  125. DB::flushQueryLog();
  126. $service->byUuid($uid);
  127. $queries = count(DB::getQueryLog());
  128. DB::disableQueryLog();
  129. expect($queries)->toBeGreaterThan(0);
  130. });
  131. it('keeps input order and drops misses in the positional list', function () {
  132. $a = makeStudio('list-a');
  133. $b = makeStudio('list-b');
  134. $orphan = (string) Str::uuid();
  135. $list = app(UserService::class)->listByUuids([$b, $orphan, $a]);
  136. expect($list)->toHaveCount(2)
  137. ->and($list[0]['nickName'])->toBe('list-b')
  138. ->and($list[1]['nickName'])->toBe('list-a');
  139. });
  140. it('resolves either candidate through actor()', function () {
  141. $service = app(UserService::class);
  142. $uid = makeStudio('either-human');
  143. $human = UserInfo::where('userid', $uid)->first();
  144. $assistant = makeAssistant('either-ai');
  145. // 人类优先,AI 兜底,都没有给占位
  146. expect($service->actor($human, $assistant)['nickName'])->toBe('either-human')
  147. ->and($service->actor(null, $assistant)['nickName'])->toBe('either-ai')
  148. ->and($service->actor(null, null)['nickName'])->toBe('unknown');
  149. });
  150. it('always includes the matching column in the eager load spec', function () {
  151. [$human, $assistant] = UserService::eagerLoadActor();
  152. // 漏掉匹配列不会报错,只会让整列静默变 null——这条守的就是那个
  153. expect($human)->toStartWith('user:')
  154. ->and(explode(',', explode(':', $human)[1]))->toContain('userid')
  155. ->and($assistant)->toStartWith('aiModel:')
  156. ->and(explode(',', explode(':', $assistant)[1]))->toContain('uid');
  157. // 关系名可换,列白名单不跟着走样
  158. [$editor] = UserService::eagerLoadActor('editor', 'editorAssistant');
  159. expect($editor)->toStartWith('editor:')
  160. ->and(explode(',', explode(':', $editor)[1]))->toContain('userid');
  161. });
  162. it('forwards UserApi to the service unchanged', function () {
  163. $uid = makeStudio('forwarded');
  164. $service = app(UserService::class);
  165. expect(UserApi::getByUuid($uid))->toBe($service->byUuid($uid))
  166. ->and(UserApi::getIdByName('forwarded'))->toBe($uid)
  167. ->and(UserApi::getIdByUuid($uid))->toBe($service->intIdByUuid($uid))
  168. ->and(UserApi::getIntIdByName('forwarded'))->toBe($service->intIdByName('forwarded'))
  169. ->and(UserApi::getByName('forwarded'))->toBe($service->byName('forwarded'))
  170. ->and(UserApi::userInfo(null))->toBe($service->profile(null));
  171. });
  172. it('keeps the v2 quirk of returning null for a non-array list', function () {
  173. expect(UserApi::getListByUuid(null))->toBeNull()
  174. ->and(UserApi::getListByUuid('not-an-array'))->toBeNull()
  175. ->and(UserApi::getListByUuid([]))->toBeNull();
  176. });
  177. it('forwards AiAssistantApi to the service unchanged', function () {
  178. $assistant = makeAssistant('forwarded-ai');
  179. $service = app(UserService::class);
  180. expect(AiAssistantApi::getByUuid($assistant->uid))->toBe($service->assistantByUuid($assistant->uid))
  181. ->and(AiAssistantApi::userInfo($assistant))->toBe($service->assistantProfile($assistant))
  182. ->and(AiAssistantApi::userInfo(null))->toBe($service->assistantProfile(null));
  183. });
  184. it('picks a built-in logo for an assistant without an avatar', function () {
  185. $assistant = makeAssistant('logo-test', ['model' => 'gpt-4', 'url' => null]);
  186. $avatar = app(UserService::class)->assistantProfile($assistant)['avatar'];
  187. expect($avatar)->toStartWith(config('app.url').'/assets/images/avatar/')
  188. ->and($avatar)->toEndWith('.png');
  189. });