TermWriteAsAiModelTest.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. <?php
  2. use App\Models\AiModel;
  3. use App\Models\DhammaTerm;
  4. use Illuminate\Foundation\Testing\RefreshDatabase;
  5. uses(RefreshDatabase::class);
  6. /**
  7. * 术语的写入链路与句子一致:人类签出 channel 的 access token,AI 模型带着
  8. * 它以自己的身份建/改术语。这里的核心同样是署名——editor_uid 必须是模型。
  9. */
  10. /** 人类签出 channel access token,并取出模型身份 token。 */
  11. function termWriteTokens($test, string $human, string $channel, AiModel $model): array
  12. {
  13. $accessToken = $test->postJson('/api/v2/access-token', [
  14. 'payload' => [[
  15. 'res_type' => 'channel',
  16. 'res_id' => $channel,
  17. 'power' => 'edit',
  18. 'book' => 0,
  19. ]],
  20. ], authHeader($human))
  21. ->assertOk()
  22. ->assertJsonPath('data.count', 1)
  23. ->json('data.rows.0.token');
  24. $modelToken = $test->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  25. ->assertOk()
  26. ->json('data.token');
  27. return [$accessToken, $modelToken];
  28. }
  29. it('creates a channel term attributed to the ai model', function () {
  30. $human = makeStudio('tester');
  31. $channel = makeChannel($human);
  32. $model = AiModel::factory()->ownedBy($human)->create(['name' => 'claude-opus-5']);
  33. [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
  34. $this->postJson('/api/v2/terms', [
  35. 'word' => 'satipaṭṭhāna',
  36. 'meaning' => '念处',
  37. 'other_meaning' => '念住',
  38. 'note' => '四念处之念处。',
  39. 'tag' => 'abhidhamma',
  40. 'channel' => $channel,
  41. 'access_token' => $accessToken,
  42. ], ['Authorization' => 'Bearer '.$modelToken])
  43. ->assertOk()
  44. ->assertJsonPath('data.word', 'satipaṭṭhāna');
  45. $saved = DhammaTerm::where('word', 'satipaṭṭhāna')->first();
  46. expect($saved)->not->toBeNull();
  47. expect($saved->meaning)->toBe('念处');
  48. expect($saved->channal)->toBe($channel);
  49. // 核心断言:署名归模型,而不是发起操作的人类
  50. expect($saved->editor_uid)->toBe($model->uid);
  51. expect($saved->editor_uid)->not->toBe($human);
  52. // editor_id 存人类的自增 sn,模型没有;0 与「缺省/未知」撞车,故记 -1
  53. expect($saved->editor_id)->toBe(-1);
  54. // owner 仍是 channel 所属 studio
  55. expect($saved->owner)->toBe($human);
  56. });
  57. it('refuses to create a term without an access token', function () {
  58. $human = makeStudio('tester');
  59. $channel = makeChannel($human);
  60. $model = AiModel::factory()->ownedBy($human)->create();
  61. $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  62. ->json('data.token');
  63. $this->postJson('/api/v2/terms', [
  64. 'word' => 'satipaṭṭhāna',
  65. 'meaning' => '念处',
  66. 'channel' => $channel,
  67. ], ['Authorization' => 'Bearer '.$modelToken])
  68. ->assertStatus(403);
  69. expect(DhammaTerm::count())->toBe(0);
  70. });
  71. it('refuses an access token issued for a different channel', function () {
  72. $human = makeStudio('tester');
  73. $channel = makeChannel($human, 'mine');
  74. $otherChannel = makeChannel(makeStudio('someone-else'), 'not mine');
  75. $model = AiModel::factory()->ownedBy($human)->create();
  76. [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
  77. $this->postJson('/api/v2/terms', [
  78. 'word' => 'satipaṭṭhāna',
  79. 'meaning' => '念处',
  80. 'channel' => $otherChannel,
  81. 'access_token' => $accessToken,
  82. ], ['Authorization' => 'Bearer '.$modelToken])
  83. ->assertStatus(403);
  84. expect(DhammaTerm::count())->toBe(0);
  85. });
  86. it('updates a term as the ai model, leaving unsubmitted fields untouched', function () {
  87. $human = makeStudio('tester');
  88. $channel = makeChannel($human);
  89. $model = AiModel::factory()->ownedBy($human)->create();
  90. [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
  91. $guid = $this->postJson('/api/v2/terms', [
  92. 'word' => 'satipaṭṭhāna',
  93. 'meaning' => '念处',
  94. 'note' => '原有的注解',
  95. 'tag' => 'abhidhamma',
  96. 'channel' => $channel,
  97. 'access_token' => $accessToken,
  98. ], ['Authorization' => 'Bearer '.$modelToken])
  99. ->assertOk()
  100. ->json('data.guid');
  101. $createTime = DhammaTerm::find($guid)->create_time;
  102. // 只提交 meaning:note/tag 必须原样保留
  103. $this->putJson("/api/v2/terms/{$guid}", [
  104. 'meaning' => '念住',
  105. 'access_token' => $accessToken,
  106. ], ['Authorization' => 'Bearer '.$modelToken])
  107. ->assertOk()
  108. ->assertJsonPath('data.meaning', '念住');
  109. $saved = DhammaTerm::find($guid);
  110. expect($saved->meaning)->toBe('念住');
  111. expect($saved->word)->toBe('satipaṭṭhāna');
  112. expect($saved->note)->toBe('原有的注解');
  113. expect($saved->tag)->toBe('abhidhamma');
  114. expect($saved->editor_uid)->toBe($model->uid);
  115. expect($saved->editor_id)->toBe(-1);
  116. // create_time 是创建时刻,改动不该刷新它
  117. expect($saved->create_time)->toBe($createTime);
  118. });
  119. it('refuses to update a term with no access token', function () {
  120. $human = makeStudio('tester');
  121. $channel = makeChannel($human);
  122. $model = AiModel::factory()->ownedBy($human)->create();
  123. [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
  124. $guid = $this->postJson('/api/v2/terms', [
  125. 'word' => 'satipaṭṭhāna',
  126. 'meaning' => '念处',
  127. 'channel' => $channel,
  128. 'access_token' => $accessToken,
  129. ], ['Authorization' => 'Bearer '.$modelToken])->json('data.guid');
  130. $this->putJson("/api/v2/terms/{$guid}", [
  131. 'meaning' => '篡改',
  132. ], ['Authorization' => 'Bearer '.$modelToken])
  133. ->assertStatus(403);
  134. expect(DhammaTerm::find($guid)->meaning)->toBe('念处');
  135. });
  136. it('refuses a studio term written into someone elses studio', function () {
  137. $human = makeStudio('tester');
  138. $victim = makeStudio('victim');
  139. // 人类身份,但把 owner 指向别人的 studio
  140. $this->postJson('/api/v2/terms', [
  141. 'word' => 'satipaṭṭhāna',
  142. 'meaning' => '念处',
  143. 'studioName' => 'victim',
  144. 'language' => 'zh-Hans',
  145. ], authHeader($human))
  146. ->assertStatus(403);
  147. expect(DhammaTerm::count())->toBe(0);
  148. // 自己的 studio 则放行
  149. $this->postJson('/api/v2/terms', [
  150. 'word' => 'satipaṭṭhāna',
  151. 'meaning' => '念处',
  152. 'studioName' => 'tester',
  153. 'language' => 'zh-Hans',
  154. ], authHeader($human))->assertOk();
  155. expect(DhammaTerm::where('owner', $human)->count())->toBe(1);
  156. // 人类写入不受影响:editor_id 仍是 token 里的用户 sn
  157. expect(DhammaTerm::where('owner', $human)->first()->editor_id)->toBe(1);
  158. });
  159. it('refuses to create a studio-level term as an ai model', function () {
  160. $human = makeStudio('tester');
  161. $model = AiModel::factory()->ownedBy($human)->create();
  162. $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  163. ->json('data.token');
  164. // 不给 channel:模型没有任何可核验的授权,必须被拒
  165. $this->postJson('/api/v2/terms', [
  166. 'word' => 'satipaṭṭhāna',
  167. 'meaning' => '念处',
  168. 'studioName' => 'tester',
  169. 'language' => 'zh-Hans',
  170. ], ['Authorization' => 'Bearer '.$modelToken])
  171. ->assertStatus(403);
  172. expect(DhammaTerm::count())->toBe(0);
  173. });
  174. it('refuses to edit a studio-level term as an ai model', function () {
  175. $human = makeStudio('tester');
  176. $model = AiModel::factory()->ownedBy($human)->create();
  177. // 人类先建一条 studio 级术语
  178. $guid = $this->postJson('/api/v2/terms', [
  179. 'word' => 'satipaṭṭhāna',
  180. 'meaning' => '念处',
  181. 'studioName' => 'tester',
  182. 'language' => 'zh-Hans',
  183. ], authHeader($human))->assertOk()->json('data.guid');
  184. $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  185. ->json('data.token');
  186. $this->putJson("/api/v2/terms/{$guid}", [
  187. 'meaning' => '篡改',
  188. ], ['Authorization' => 'Bearer '.$modelToken])
  189. ->assertStatus(403);
  190. expect(DhammaTerm::find($guid)->meaning)->toBe('念处');
  191. });
  192. it('rejects a duplicate word+tag in the same channel written by an ai model', function () {
  193. $human = makeStudio('tester');
  194. $channel = makeChannel($human);
  195. $model = AiModel::factory()->ownedBy($human)->create();
  196. [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
  197. $payload = [
  198. 'word' => 'satipaṭṭhāna',
  199. 'meaning' => '念处',
  200. 'tag' => 'abhidhamma',
  201. 'channel' => $channel,
  202. 'access_token' => $accessToken,
  203. ];
  204. $this->postJson('/api/v2/terms', $payload, ['Authorization' => 'Bearer '.$modelToken])
  205. ->assertOk();
  206. // 查重按 channel,不按 owner——模型的 uid 与落库的 owner 不等,
  207. // 按 owner 查会永远查不到重复,同一个词被反复插入。
  208. $this->postJson('/api/v2/terms', $payload, ['Authorization' => 'Bearer '.$modelToken])
  209. ->assertOk()
  210. ->assertJsonPath('message', 'word existed');
  211. expect(DhammaTerm::where('word', 'satipaṭṭhāna')->count())->toBe(1);
  212. });
  213. it('exposes the channel of a channel term', function () {
  214. $human = makeStudio('tester');
  215. $channel = makeChannel($human, 'my channel');
  216. $model = AiModel::factory()->ownedBy($human)->create();
  217. [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
  218. $guid = $this->postJson('/api/v2/terms', [
  219. 'word' => 'satipaṭṭhāna',
  220. 'meaning' => '念处',
  221. 'channel' => $channel,
  222. 'access_token' => $accessToken,
  223. ], ['Authorization' => 'Bearer '.$modelToken])->json('data.guid');
  224. // channel 信息取自 channal 列;此前读的是模型上并不存在的 channel_id
  225. $this->getJson("/api/v2/terms/{$guid}", authHeader($human))
  226. ->assertOk()
  227. ->assertJsonPath('data.channel.name', 'my channel');
  228. });