seed(CityWorldSeeder::class); } /** * Create an active user paired with its in-world character. */ private function makeCitizen(): array { $user = User::factory()->create([ 'status' => User::STATUS_ACTIVE, 'is_active' => true, ]); return [$user, Character::ensureForUser($user)]; } /** * Simulate a human actively using the account (fresh session row). */ private function makePeerLive(User $user): void { Db::table('sessions')->insert([ 'id' => Str::random(40), 'user_id' => $user->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'phpunit', 'payload' => '', 'last_activity' => now()->getTimestamp(), ]); } /** * Character-to-character talks are stored per day and visible from * both sides of the conversation. */ public function test_character_talk_is_registered_per_day_for_both_sides(): void { [$userA, $me] = $this->makeCitizen(); [, $them] = $this->makeCitizen(); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'Oi, tudo bem?', ]) ->assertCreated() ->assertJsonPath('from', 'me') ->assertJsonPath('talked_on', now()->toDateString()); $this->assertDatabaseHas('person_talks', [ 'from_character_id' => $me->id, 'to_character_id' => $them->id, 'to_agent_id' => null, 'body' => 'Oi, tudo bem?', ]); $stored = PersonTalk::query()->where('body', 'Oi, tudo bem?')->firstOrFail(); $this->assertSame(now()->toDateString(), $stored->talked_on->toDateString()); } /** * The peer reads the same row as an incoming message and can answer * back through the same endpoint. */ public function test_conversation_is_shared_between_both_characters(): void { [$userA, $me] = $this->makeCitizen(); [$userB, $them] = $this->makeCitizen(); // Both sides driven by live humans → pure relay, no auto-replies. $this->makePeerLive($userA); $this->makePeerLive($userB); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'Oi, tudo bem?', ]) ->assertCreated(); $this->actingAs($userB) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $me->id, 'message' => 'Tudo ótimo!', ]) ->assertCreated(); $this->actingAs($userB) ->getJson('/talk/person?kind=character&peer='.$me->id) ->assertOk() ->assertJsonPath('peer.kind', 'character') ->assertJsonPath('messages.0.from', 'them') ->assertJsonPath('messages.0.body', 'Oi, tudo bem?') ->assertJsonPath('messages.1.from', 'me') ->assertJsonPath('messages.1.body', 'Tudo ótimo!'); } /** * Peer and payload validation on the send endpoint. */ public function test_talk_validates_peer_and_message(): void { [$userA, $me] = $this->makeCitizen(); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $me->id, 'message' => 'oi', ]) ->assertStatus(422); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'alien', 'peer' => 1, 'message' => 'oi', ]) ->assertStatus(422); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => 999999, 'message' => 'oi', ]) ->assertNotFound(); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => 999999, ]) ->assertStatus(422); } /** * Agent talks store the message, answer after the response and send * the multi-day history upstream while keeping PII out (RULES.md 2.2). */ public function test_agent_talk_replies_using_daily_history_as_context(): void { config(['ai.openrouter.api_key' => 'test-key']); Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [['message' => ['content' => 'Praça renovada, como combinamos ontem!']]], ]), ]); [$userA, $me] = $this->makeCitizen(); $agent = AiAgent::create(['name' => 'Prefeito', 'type' => 'mayor']); // Yesterday's exchange is part of the persistent context. PersonTalk::create([ 'from_character_id' => $me->id, 'to_agent_id' => $agent->id, 'body' => 'Ontem conversamos sobre a praça', 'talked_on' => now()->subDay()->toDateString(), ]); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'agent', 'peer' => $agent->id, 'message' => 'E a praça, fica pronta hoje?', ]) ->assertCreated(); // The after-response job ran during the request lifecycle. $this->assertDatabaseHas('person_talks', [ 'from_agent_id' => $agent->id, 'to_character_id' => $me->id, 'body' => 'Praça renovada, como combinamos ontem!', ]); $reply = PersonTalk::query()->where('body', 'Praça renovada, como combinamos ontem!')->firstOrFail(); $this->assertSame(now()->toDateString(), $reply->talked_on->toDateString()); $agent->refresh(); $this->assertSame('active', $agent->status); $this->assertSame(0, (int) $agent->error_count); $this->assertNotNull($agent->last_think_at); $this->assertNotEmpty($agent->last_decision); $userEmail = $userA->email; $userName = $userA->name; Http::assertSent(function ($request) use ($userEmail, $userName): bool { $payload = $request->data(); $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE); // Yesterday's line and today's line reach the model (thread kept). $hasHistory = str_contains($encoded, 'Ontem conversamos sobre a praça') && str_contains($encoded, 'E a praça, fica pronta hoje?'); // No registration data upstream (RULES.md 2.2). $leaksPii = str_contains($encoded, $userEmail) || str_contains($encoded, $userName); return $hasHistory && ! $leaksPii; }); $this->actingAs($userA) ->getJson('/talk/person?kind=agent&peer='.$agent->id) ->assertOk() ->assertJsonPath('peer.kind', 'agent') ->assertJsonPath('peer.status', 'active') ->assertJsonPath('thinking', false) ->assertJsonPath('messages.0.body', 'Ontem conversamos sobre a praça') ->assertJsonPath('messages.0.from', 'me') ->assertJsonPath('messages.1.body', 'E a praça, fica pronta hoje?') ->assertJsonPath('messages.2.body', 'Praça renovada, como combinamos ontem!') ->assertJsonPath('messages.2.from', 'them') ->assertJsonPath('messages.2.talked_on', now()->toDateString()); } /** * Without an upstream key the conversation still completes: the * agent stores the localized fallback and counts the failure for * backoff (RULES.md 2.4). */ public function test_agent_talk_without_api_key_stores_fallback_reply(): void { config(['ai.openrouter.api_key' => null]); Http::preventStrayRequests(); [$userA, $me] = $this->makeCitizen(); $agent = AiAgent::create(['name' => 'Lojista', 'type' => 'shopkeeper']); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'agent', 'peer' => $agent->id, 'message' => 'Bom dia!', ]) ->assertCreated(); $this->assertDatabaseHas('person_talks', [ 'from_agent_id' => $agent->id, 'to_character_id' => $me->id, 'body' => __('city.chat_error'), 'is_fallback' => true, ]); $fallback = PersonTalk::query()->where('body', __('city.chat_error'))->firstOrFail(); $this->assertSame(now()->toDateString(), $fallback->talked_on->toDateString()); $this->assertTrue((bool) $fallback->is_fallback); $agent->refresh(); $this->assertSame(1, (int) $agent->error_count); $this->actingAs($userA) ->getJson('/talk/person?kind=agent&peer='.$agent->id) ->assertOk() ->assertJsonPath('thinking', false); } /** * While the agent reply is still pending the UI is told to keep * showing the thinking state. */ public function test_agent_talk_reports_thinking_until_reply_lands(): void { [$userA, $me] = $this->makeCitizen(); $agent = AiAgent::create(['name' => 'Guia', 'type' => 'pedestrian']); // Pending row without dispatching a reply (simulate in-flight). PersonTalk::create([ 'from_character_id' => $me->id, 'to_agent_id' => $agent->id, 'body' => 'Oi!', 'talked_on' => now()->toDateString(), ]); $this->actingAs($userA) ->getJson('/talk/person?kind=agent&peer='.$agent->id) ->assertOk() ->assertJsonPath('thinking', true); } /** * History spans multiple days so later conversations keep the thread. */ public function test_history_returns_previous_days_in_order(): void { [$userA, $me] = $this->makeCitizen(); [, $them] = $this->makeCitizen(); foreach ([ [now()->subDays(2), 'Primeira conversa'], [now()->subDay(), 'Segunda conversa'], [now(), 'Terceira conversa'], ] as [$day, $body]) { PersonTalk::create([ 'from_character_id' => $me->id, 'to_character_id' => $them->id, 'body' => $body, 'talked_on' => $day->toDateString(), ]); } $response = $this->actingAs($userA) ->getJson('/talk/person?kind=character&peer='.$them->id) ->assertOk(); $messages = $response->json('messages'); $this->assertCount(3, $messages); $this->assertSame('Primeira conversa', $messages[0]['body']); $this->assertSame(now()->subDays(2)->toDateString(), $messages[0]['talked_on']); $this->assertSame('Terceira conversa', $messages[2]['body']); $this->assertSame(now()->toDateString(), $messages[2]['talked_on']); } /** * A character peer without a live human session (seeded citizen, * demo account like "Maria") answers through the AI persona, using * the daily history as context and keeping PII out of the payload. */ public function test_silent_citizen_peer_answers_with_ai_reply(): void { config(['ai.openrouter.api_key' => 'test-key']); Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade hoje.']]], ]), ]); [$userA, $me] = $this->makeCitizen(); [$userB, $them] = $this->makeCitizen(); // no session → silent peer PersonTalk::create([ 'from_character_id' => $me->id, 'to_character_id' => $them->id, 'body' => 'Ontem te vi na praça', 'talked_on' => now()->subDay()->toDateString(), ]); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'Oi, tudo bem por aí?', ]) ->assertCreated(); // The silent peer replied during the after-response phase. $reply = PersonTalk::query() ->where('from_character_id', $them->id) ->where('to_character_id', $me->id) ->first(); $this->assertNotNull($reply); $this->assertSame('Oi! Tudo tranquilo na cidade hoje.', $reply->body); $this->assertSame(now()->toDateString(), $reply->talked_on->toDateString()); $peerEmail = $userB->email; Http::assertSent(function ($request) use ($peerEmail): bool { $encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE); // Thread kept… and no registration data upstream (RULES.md 2.2). return str_contains($encoded, 'Ontem te vi na praça') && str_contains($encoded, 'Oi, tudo bem por aí?') && ! str_contains($encoded, $peerEmail); }); $this->actingAs($userA) ->getJson('/talk/person?kind=character&peer='.$them->id) ->assertOk() ->assertJsonPath('thinking', false); } /** * A peer driven by a live human session is relayed as-is and never * answered by the AI on their behalf. */ public function test_live_human_peer_is_not_impersonated(): void { config(['ai.openrouter.api_key' => 'test-key']); Http::preventStrayRequests(); [$userA, $me] = $this->makeCitizen(); [$userB, $them] = $this->makeCitizen(); $this->makePeerLive($userB); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'Você está aí?', ]) ->assertCreated(); $this->assertSame(0, PersonTalk::query() ->where('from_character_id', $them->id) ->where('to_character_id', $me->id) ->count()); $this->actingAs($userA) ->getJson('/talk/person?kind=character&peer='.$them->id) ->assertOk() ->assertJsonPath('thinking', false); } /** * Without an API key the silent peer still answers with the stored * fallback so the chat never hangs waiting. */ public function test_silent_citizen_falls_back_without_api_key(): void { config(['ai.openrouter.api_key' => null]); Http::preventStrayRequests(); [$userA, $me] = $this->makeCitizen(); [, $them] = $this->makeCitizen(); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'Olá?', ]) ->assertCreated(); $this->assertDatabaseHas('person_talks', [ 'from_character_id' => $them->id, 'to_character_id' => $me->id, 'body' => __('city.chat_error'), 'is_fallback' => true, ]); $this->actingAs($userA) ->getJson('/talk/person?kind=character&peer='.$them->id) ->assertOk() ->assertJsonPath('thinking', false); } /** * Error-fallback rows stay visible in the chat but are never sent * back to the model as dialogue — an apology in the context would * skew the persona into a support-bot tone or identity confusion. */ public function test_fallback_rows_are_excluded_from_model_context(): void { config(['ai.openrouter.api_key' => 'test-key']); Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [['message' => ['content' => 'Praça fechada para obra, já passa.']]], ]), ]); [$userA, $me] = $this->makeCitizen(); [$userB, $them] = $this->makeCitizen(); // Yesterday: a real line plus an error placeholder. PersonTalk::create([ 'from_character_id' => $me->id, 'to_character_id' => $them->id, 'body' => 'E a praça, abre hoje?', 'talked_on' => now()->subDay()->toDateString(), ]); PersonTalk::create([ 'from_character_id' => $them->id, 'to_character_id' => $me->id, 'body' => __('city.chat_error'), 'talked_on' => now()->subDay()->toDateString(), 'is_fallback' => true, ]); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'E aí, e a obra?', ]) ->assertCreated(); Http::assertSent(function ($request): bool { $encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE); return str_contains($encoded, 'E a praça, abre hoje?') && str_contains($encoded, 'E aí, e a obra?') && ! str_contains($encoded, __('city.chat_error')); }); // The stored reply is a real answer, not another placeholder. $reply = PersonTalk::query() ->where('from_character_id', $them->id) ->where('is_fallback', false) ->orderByDesc('id') ->first(); $this->assertNotNull($reply); $this->assertSame('Praça fechada para obra, já passa.', $reply->body); } /** * Model resolution priority: column → env map (id/name) → pool slot, * always inside the allow-list (RULES.md 2.1) and stable per id. */ public function test_model_resolution_priority_and_allow_list(): void { $mapped = 'openrouter/free'; $pooled = 'nvidia/nemotron-3.5-lightning:free'; config([ 'ai.models' => [$pooled, $mapped], 'ai.character_models' => [ 'maria' => $mapped, '7' => $pooled, 'bad' => 'not/allowed-model', ], ]); // 1) Character column wins over everything. $this->assertSame( $mapped, PersonTalkController::resolveModel($mapped, 'Qualquer', 99) ); // 2) Env map by name (case-insensitive) and by id. $this->assertSame($mapped, PersonTalkController::resolveModel(null, 'Maria', 3)); $this->assertSame($pooled, PersonTalkController::resolveModel(null, 'Outro', 7)); // 3) Non allow-listed entries are ignored → deterministic pool slot. $fromPool = PersonTalkController::resolveModel('not/allowed-model', 'Bad', 4); $this->assertContains($fromPool, [$pooled, $mapped]); $this->assertNotSame('not/allowed-model', $fromPool); // 4) Empty map/pool falls back to the allow-listed default. config(['ai.character_models' => [], 'ai.models' => []]); $default = config('ai.default_model'); $this->assertContains($default, config('ai.allowed_models')); $this->assertSame($default, PersonTalkController::resolveModel(null, 'X', 1)); // 5) Same character always resolves to the same model. config(['ai.models' => [$pooled, $mapped]]); $stable = PersonTalkController::resolveModel(null, 'Estável', 5); $this->assertSame($stable, PersonTalkController::resolveModel(null, 'Estável', 5)); } /** * The identity/personality profile and the resolved model travel in * the upstream payload while registration data stays out (RULES.md 2.2). */ public function test_identity_and_model_flow_into_upstream_payload(): void { config([ 'ai.openrouter.api_key' => 'test-key', 'ai.models' => ['liquid/lfm-2.5-2.6b:free'], 'ai.character_models' => [], ]); Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade.']]], ]), ]); [$userA, $me] = $this->makeCitizen(); [$userB, $them] = $this->makeCitizen(); $them->update([ 'ai_age' => 34, 'ai_gender' => 'feminino', 'ai_personality' => 'Extrovertido e curiosa, adora puxar conversa.', 'ai_bio' => 'Moradora do centro, vive nas novidades do bairro.', ]); $this->actingAs($userA) ->postJson('/talk/person', [ 'kind' => 'character', 'peer' => $them->id, 'message' => 'Oi, tudo bem?', ]) ->assertCreated(); $peerEmail = $userB->email; $peerName = $them->name; Http::assertSent(function ($request) use ($peerEmail, $peerName): bool { $payload = $request->data(); $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE); $system = (string) ($payload['messages'][0]['content'] ?? ''); return $payload['model'] === 'liquid/lfm-2.5-2.6b:free' && str_contains($system, 'Idade: 34') && str_contains($system, 'Sexo: feminino') && str_contains($system, 'Extrovertido e curiosa') && str_contains($system, 'Moradora do centro') && str_contains($system, 'Nome: '.$peerName) && ! str_contains($encoded, $peerEmail); }); // The reply from the fake model is stored normally. $this->assertNotNull(PersonTalk::query() ->where('from_character_id', $them->id) ->where('body', 'Oi! Tudo tranquilo na cidade.') ->first()); } /** * Fresh citizens are created with a ready-to-use default persona. */ public function test_new_characters_receive_default_persona(): void { [, $character] = $this->makeCitizen(); $this->assertNotNull($character->ai_age); $this->assertGreaterThanOrEqual(18, $character->ai_age); $this->assertLessThanOrEqual(68, $character->ai_age); $this->assertContains($character->ai_personality, Character::AI_PERSONALITIES); } }