postJson('/api/v2/access-token', [ 'payload' => [[ 'res_type' => 'channel', 'res_id' => $channel, 'power' => 'edit', 'book' => 0, ]], ], authHeader($human)) ->assertOk() ->assertJsonPath('data.count', 1) ->json('data.rows.0.token'); $modelToken = $test->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human)) ->assertOk() ->json('data.token'); return [$accessToken, $modelToken]; } it('creates a channel term attributed to the ai model', function () { $human = makeStudio('tester'); $channel = makeChannel($human); $model = AiModel::factory()->ownedBy($human)->create(['name' => 'claude-opus-5']); [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model); $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'other_meaning' => '念住', 'note' => '四念处之念处。', 'tag' => 'abhidhamma', 'channel' => $channel, 'access_token' => $accessToken, ], ['Authorization' => 'Bearer '.$modelToken]) ->assertOk() ->assertJsonPath('data.word', 'satipaṭṭhāna'); $saved = DhammaTerm::where('word', 'satipaṭṭhāna')->first(); expect($saved)->not->toBeNull(); expect($saved->meaning)->toBe('念处'); expect($saved->channal)->toBe($channel); // 核心断言:署名归模型,而不是发起操作的人类 expect($saved->editor_uid)->toBe($model->uid); expect($saved->editor_uid)->not->toBe($human); // editor_id 存人类的自增 sn,模型没有;0 与「缺省/未知」撞车,故记 -1 expect($saved->editor_id)->toBe(-1); // owner 仍是 channel 所属 studio expect($saved->owner)->toBe($human); }); it('refuses to create a term without an access token', function () { $human = makeStudio('tester'); $channel = makeChannel($human); $model = AiModel::factory()->ownedBy($human)->create(); $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human)) ->json('data.token'); $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'channel' => $channel, ], ['Authorization' => 'Bearer '.$modelToken]) ->assertStatus(403); expect(DhammaTerm::count())->toBe(0); }); it('refuses an access token issued for a different channel', function () { $human = makeStudio('tester'); $channel = makeChannel($human, 'mine'); $otherChannel = makeChannel(makeStudio('someone-else'), 'not mine'); $model = AiModel::factory()->ownedBy($human)->create(); [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model); $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'channel' => $otherChannel, 'access_token' => $accessToken, ], ['Authorization' => 'Bearer '.$modelToken]) ->assertStatus(403); expect(DhammaTerm::count())->toBe(0); }); it('updates a term as the ai model, leaving unsubmitted fields untouched', function () { $human = makeStudio('tester'); $channel = makeChannel($human); $model = AiModel::factory()->ownedBy($human)->create(); [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model); $guid = $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'note' => '原有的注解', 'tag' => 'abhidhamma', 'channel' => $channel, 'access_token' => $accessToken, ], ['Authorization' => 'Bearer '.$modelToken]) ->assertOk() ->json('data.guid'); $createTime = DhammaTerm::find($guid)->create_time; // 只提交 meaning:note/tag 必须原样保留 $this->putJson("/api/v2/terms/{$guid}", [ 'meaning' => '念住', 'access_token' => $accessToken, ], ['Authorization' => 'Bearer '.$modelToken]) ->assertOk() ->assertJsonPath('data.meaning', '念住'); $saved = DhammaTerm::find($guid); expect($saved->meaning)->toBe('念住'); expect($saved->word)->toBe('satipaṭṭhāna'); expect($saved->note)->toBe('原有的注解'); expect($saved->tag)->toBe('abhidhamma'); expect($saved->editor_uid)->toBe($model->uid); expect($saved->editor_id)->toBe(-1); // create_time 是创建时刻,改动不该刷新它 expect($saved->create_time)->toBe($createTime); }); it('refuses to update a term with no access token', function () { $human = makeStudio('tester'); $channel = makeChannel($human); $model = AiModel::factory()->ownedBy($human)->create(); [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model); $guid = $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'channel' => $channel, 'access_token' => $accessToken, ], ['Authorization' => 'Bearer '.$modelToken])->json('data.guid'); $this->putJson("/api/v2/terms/{$guid}", [ 'meaning' => '篡改', ], ['Authorization' => 'Bearer '.$modelToken]) ->assertStatus(403); expect(DhammaTerm::find($guid)->meaning)->toBe('念处'); }); it('refuses a studio term written into someone elses studio', function () { $human = makeStudio('tester'); $victim = makeStudio('victim'); // 人类身份,但把 owner 指向别人的 studio $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'studioName' => 'victim', 'language' => 'zh-Hans', ], authHeader($human)) ->assertStatus(403); expect(DhammaTerm::count())->toBe(0); // 自己的 studio 则放行 $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'studioName' => 'tester', 'language' => 'zh-Hans', ], authHeader($human))->assertOk(); expect(DhammaTerm::where('owner', $human)->count())->toBe(1); // 人类写入不受影响:editor_id 仍是 token 里的用户 sn expect(DhammaTerm::where('owner', $human)->first()->editor_id)->toBe(1); }); it('refuses to create a studio-level term as an ai model', function () { $human = makeStudio('tester'); $model = AiModel::factory()->ownedBy($human)->create(); $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human)) ->json('data.token'); // 不给 channel:模型没有任何可核验的授权,必须被拒 $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'studioName' => 'tester', 'language' => 'zh-Hans', ], ['Authorization' => 'Bearer '.$modelToken]) ->assertStatus(403); expect(DhammaTerm::count())->toBe(0); }); it('refuses to edit a studio-level term as an ai model', function () { $human = makeStudio('tester'); $model = AiModel::factory()->ownedBy($human)->create(); // 人类先建一条 studio 级术语 $guid = $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'studioName' => 'tester', 'language' => 'zh-Hans', ], authHeader($human))->assertOk()->json('data.guid'); $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human)) ->json('data.token'); $this->putJson("/api/v2/terms/{$guid}", [ 'meaning' => '篡改', ], ['Authorization' => 'Bearer '.$modelToken]) ->assertStatus(403); expect(DhammaTerm::find($guid)->meaning)->toBe('念处'); }); it('rejects a duplicate word+tag in the same channel written by an ai model', function () { $human = makeStudio('tester'); $channel = makeChannel($human); $model = AiModel::factory()->ownedBy($human)->create(); [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model); $payload = [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'tag' => 'abhidhamma', 'channel' => $channel, 'access_token' => $accessToken, ]; $this->postJson('/api/v2/terms', $payload, ['Authorization' => 'Bearer '.$modelToken]) ->assertOk(); // 查重按 channel,不按 owner——模型的 uid 与落库的 owner 不等, // 按 owner 查会永远查不到重复,同一个词被反复插入。 $this->postJson('/api/v2/terms', $payload, ['Authorization' => 'Bearer '.$modelToken]) ->assertOk() ->assertJsonPath('message', 'word existed'); expect(DhammaTerm::where('word', 'satipaṭṭhāna')->count())->toBe(1); }); it('exposes the channel of a channel term', function () { $human = makeStudio('tester'); $channel = makeChannel($human, 'my channel'); $model = AiModel::factory()->ownedBy($human)->create(); [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model); $guid = $this->postJson('/api/v2/terms', [ 'word' => 'satipaṭṭhāna', 'meaning' => '念处', 'channel' => $channel, 'access_token' => $accessToken, ], ['Authorization' => 'Bearer '.$modelToken])->json('data.guid'); // channel 信息取自 channal 列;此前读的是模型上并不存在的 channel_id $this->getJson("/api/v2/terms/{$guid}", authHeader($human)) ->assertOk() ->assertJsonPath('data.channel.name', 'my channel'); });