Files
hado/core/tests/Feature/McpToolsTest.php

222 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Mcp\HadoServer;
use App\Mcp\Tools\CreateEvent;
use App\Mcp\Tools\DeleteEvent;
use App\Mcp\Tools\GetEvent;
use App\Mcp\Tools\GetProfile;
use App\Mcp\Tools\ListEvents;
use App\Mcp\Tools\MarkAck;
use App\Mcp\Tools\MarkDone;
use App\Mcp\Tools\UpdateEvent;
use App\Mcp\Tools\UpdateProfile;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;
class McpToolsTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Source $docs;
protected function setUp(): void
{
parent::setUp();
config(['hado.default_tz' => 'UTC']);
CarbonImmutable::setTestNow('2026-09-05T12:00:00Z');
$this->user = User::create(['login' => 'nikita', 'tz' => 'UTC'])->fresh(); // дефолты тихих часов — из БД
$this->docs = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
// Как AuthenticateAgent: пользователь токена лежит в атрибутах текущего запроса
$this->app['request']->attributes->set('user', $this->user);
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
private function docsEvent(User $user, EventState $state = EventState::Today, string $topic = 't', string $due = '2026-09-05'): Event
{
return Event::create([
'source_id' => $this->docs->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => '24:00:00', 'fire_on' => $due,
'after_due' => AfterDue::Keep, 'payload' => ['title' => "Docs $topic", 'done_label' => 'Поздравил'], 'state' => $state,
'quiet_until' => CarbonImmutable::now()->subDay(),
]);
}
/** Своё событие через тул; id — из базы, TestResponse его наружу не отдаёт. */
private function createOwn(string $title = 'Виза', string $due = '2026-10-05'): int
{
HadoServer::tool(CreateEvent::class, ['title' => $title, 'due_at' => $due])->assertOk();
return Event::orderByDesc('id')->firstOrFail()->id;
}
// --- чтение ---
public function test_list_events_shows_active_events_of_all_sources_with_editable_flag(): void
{
$this->docsEvent($this->user, EventState::Today, 'a');
$this->docsEvent($this->user, EventState::Done, 'closed');
$other = User::create(['login' => 'other', 'tz' => 'UTC']);
$this->docsEvent($other, EventState::Today, 'foreign');
HadoServer::tool(CreateEvent::class, ['title' => 'Своё', 'due_at' => '2026-10-01'])->assertOk();
HadoServer::tool(ListEvents::class)->assertOk()->assertStructuredContent(fn (AssertableJson $j) => $j
->count('events', 2)
->where('events.0.source', 'docs')->where('events.0.editable', false)
->where('events.1.source', 'manual')->where('events.1.editable', true)
->etc());
}
public function test_list_events_filters_by_state_and_archive(): void
{
$this->docsEvent($this->user, EventState::Today, 'a');
$this->docsEvent($this->user, EventState::Scheduled, 'b', '2026-12-01');
$this->docsEvent($this->user, EventState::Expired, 'c', '2026-08-01');
HadoServer::tool(ListEvents::class, ['state' => 'scheduled'])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->count('events', 1)->where('events.0.topic', 'b')->etc());
HadoServer::tool(ListEvents::class, ['archive' => true])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->count('events', 1)->where('events.0.topic', 'c')->where('events.0.closed_as', 'Истекло')->etc());
HadoServer::tool(ListEvents::class, ['state' => 'garbage'])->assertHasErrors();
}
public function test_get_event_returns_own_and_hides_foreign(): void
{
$mine = $this->docsEvent($this->user);
$other = User::create(['login' => 'other', 'tz' => 'UTC']);
$foreign = $this->docsEvent($other, EventState::Today, 'f');
HadoServer::tool(GetEvent::class, ['id' => $mine->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('id', $mine->id)->where('state', 'today')->where('editable', false)->etc());
HadoServer::tool(GetEvent::class, ['id' => $foreign->id])->assertHasErrors(['не найдено']);
}
// --- свои события (manual) ---
public function test_create_event_uses_inbox_defaults(): void
{
HadoServer::tool(CreateEvent::class, ['title' => 'Виза', 'due_at' => '2026-10-05'])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j
->where('source', 'manual')->where('editable', true)->where('state', 'preparing')
->where('fire_at', '2026-09-05')->where('after_due', 'keep')->where('due_at', '2026-10-05')
->where('payload.title', 'Виза')->where('payload.done_label', 'Сделано')
->etc());
$this->assertSame($this->user->id, Event::sole()->user_id);
}
public function test_create_event_honours_explicit_fields(): void
{
HadoServer::tool(CreateEvent::class, [
'title' => 'Вылет', 'subtitle' => 'SU 123', 'due_at' => '2026-09-10T08:15+02:00', 'fire_at' => '2026-09-10',
'after_due' => 'expire', 'done_label' => 'Улетел', 'deep_link' => '/trips/1',
])->assertOk()->assertStructuredContent(fn (AssertableJson $j) => $j
->where('due_at', '2026-09-10T06:15:00+00:00')->where('fire_at', '2026-09-10')->where('after_due', 'expire')
->where('payload.subtitle', 'SU 123')->where('payload.done_label', 'Улетел')->where('payload.deep_link', '/trips/1')
->etc());
}
public function test_create_event_rejects_fire_after_due(): void
{
HadoServer::tool(CreateEvent::class, ['title' => 'x', 'due_at' => '2026-10-05', 'fire_at' => '2026-10-06'])->assertHasErrors();
HadoServer::tool(CreateEvent::class, ['due_at' => '2026-10-05'])->assertHasErrors(['title']);
$this->assertSame(0, Event::count());
}
public function test_update_event_edits_own_event_in_place(): void
{
$id = $this->createOwn();
HadoServer::tool(UpdateEvent::class, ['id' => $id, 'title' => 'Виза в Японию', 'subtitle' => 'посольство'])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('id', $id)->where('payload.title', 'Виза в Японию')
->where('payload.subtitle', 'посольство')->where('due_at', '2026-10-05')->where('state', 'preparing')->etc());
$this->assertSame(1, Event::count());
}
public function test_update_event_with_new_due_is_a_reschedule(): void
{
$id = $this->createOwn(due: '2026-09-01');
HadoServer::tool(MarkDone::class, ['id' => $id])->assertOk();
// Перенос: состояние пересчитывается даже из терминального, fire_at — заново от нового срока
HadoServer::tool(UpdateEvent::class, ['id' => $id, 'due_at' => '2026-12-01'])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('state', 'scheduled')->where('fire_at', '2026-11-01')->etc());
}
public function test_update_and_delete_refuse_foreign_source_events(): void
{
$docs = $this->docsEvent($this->user);
HadoServer::tool(UpdateEvent::class, ['id' => $docs->id, 'title' => 'Хак'])->assertHasErrors(['источника docs']);
HadoServer::tool(DeleteEvent::class, ['id' => $docs->id])->assertHasErrors(['источника docs']);
$this->assertSame('Docs t', $docs->fresh()->payload['title']);
$this->assertSame(EventState::Today, $docs->fresh()->state);
}
// --- действия пользователя: любой источник ---
public function test_mark_done_closes_foreign_event_like_the_inbox_button(): void
{
$docs = $this->docsEvent($this->user);
HadoServer::tool(MarkDone::class, ['id' => $docs->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('state', 'done')->where('editable', false)->etc());
$this->assertNotNull($docs->fresh()->done_at);
// повторно — no-op, без ошибки
HadoServer::tool(MarkDone::class, ['id' => $docs->id])->assertOk();
}
public function test_mark_ack_silences_until_next_checkpoint(): void
{
$docs = $this->docsEvent($this->user);
$before = $docs->quiet_until;
HadoServer::tool(MarkAck::class, ['id' => $docs->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('state', 'today')->etc());
$this->assertTrue($docs->fresh()->quiet_until->gt($before));
$other = User::create(['login' => 'other', 'tz' => 'UTC']);
HadoServer::tool(MarkAck::class, ['id' => $this->docsEvent($other, EventState::Today, 'f')->id])->assertHasErrors(['не найдено']);
}
// --- профиль ---
public function test_profile_is_readable_and_editable_with_inbox_rules(): void
{
HadoServer::tool(GetProfile::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('login', 'nikita')->where('tz', 'UTC')->where('quiet_start', '22:00')->where('quiet_end', '09:00')->etc());
HadoServer::tool(UpdateProfile::class, ['tz' => 'America/Montevideo', 'quiet_start' => '23:30'])->assertOk()
->assertStructuredContent(fn (AssertableJson $j) => $j->where('tz', 'America/Montevideo')->where('quiet_start', '23:30')->where('quiet_end', '09:00')->etc());
$this->assertSame('America/Montevideo', $this->user->fresh()->tz);
HadoServer::tool(UpdateProfile::class, ['tz' => 'Mars/Olympus'])->assertHasErrors(['tz']);
HadoServer::tool(UpdateProfile::class, ['quiet_end' => '23:45'])->assertHasErrors(['полночь']);
}
public function test_delete_event_withdraws_own_event(): void
{
$id = $this->createOwn();
HadoServer::tool(DeleteEvent::class, ['id' => $id])->assertOk()
->assertStructuredContent(['id' => $id, 'state' => 'withdrawn']);
$this->assertSame(EventState::Withdrawn, Event::findOrFail($id)->state);
}
}