From ed45a1d516765ba98bf9ffad3caf97906f2f8a3b Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Sat, 5 Sep 2026 06:10:41 -0300 Subject: [PATCH] feat: MCP tools for events (list/get/create/update/delete/done/ack); EventRules shared by /api and manual Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- .../Http/Controllers/Api/EventsController.php | 4 +- .../Http/Controllers/Me/EventsController.php | 4 +- .../app/Http/Requests/UpsertEventsRequest.php | 34 +--- core/app/Ingest/EventRules.php | 97 ++++++++++ core/app/Ingest/EventUpserter.php | 9 +- core/app/Ingest/ManualEvents.php | 81 ++++++-- core/app/Mcp/HadoServer.php | 10 +- core/app/Mcp/Tools/AgentTool.php | 50 +++++ core/app/Mcp/Tools/CreateEvent.php | 72 +++++++ core/app/Mcp/Tools/DeleteEvent.php | 35 ++++ core/app/Mcp/Tools/GetEvent.php | 29 +++ core/app/Mcp/Tools/ListEvents.php | 46 +++++ core/app/Mcp/Tools/MarkAck.php | 34 ++++ core/app/Mcp/Tools/MarkDone.php | 34 ++++ core/app/Mcp/Tools/UpdateEvent.php | 32 ++++ core/app/Models/User.php | 10 + core/tests/Feature/EventUpserterTest.php | 7 +- core/tests/Feature/McpToolsTest.php | 179 ++++++++++++++++++ 18 files changed, 711 insertions(+), 56 deletions(-) create mode 100644 core/app/Ingest/EventRules.php create mode 100644 core/app/Mcp/Tools/AgentTool.php create mode 100644 core/app/Mcp/Tools/CreateEvent.php create mode 100644 core/app/Mcp/Tools/DeleteEvent.php create mode 100644 core/app/Mcp/Tools/GetEvent.php create mode 100644 core/app/Mcp/Tools/ListEvents.php create mode 100644 core/app/Mcp/Tools/MarkAck.php create mode 100644 core/app/Mcp/Tools/MarkDone.php create mode 100644 core/app/Mcp/Tools/UpdateEvent.php create mode 100644 core/tests/Feature/McpToolsTest.php diff --git a/core/app/Http/Controllers/Api/EventsController.php b/core/app/Http/Controllers/Api/EventsController.php index 00719a6..a0c816e 100644 --- a/core/app/Http/Controllers/Api/EventsController.php +++ b/core/app/Http/Controllers/Api/EventsController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Api; -use App\Enums\EventState; use App\Http\Controllers\Controller; use App\Http\Requests\UpsertEventsRequest; use App\Ingest\EventUpserter; @@ -34,8 +33,7 @@ class EventsController extends Controller public function destroy(Request $request): Response { - $event = $this->lookup($request); - $event->update(['state' => EventState::Withdrawn]); + $this->upserter->withdraw($this->lookup($request)); return response()->noContent(); } diff --git a/core/app/Http/Controllers/Me/EventsController.php b/core/app/Http/Controllers/Me/EventsController.php index 3f2ca26..7f005f0 100644 --- a/core/app/Http/Controllers/Me/EventsController.php +++ b/core/app/Http/Controllers/Me/EventsController.php @@ -28,9 +28,7 @@ class EventsController extends Controller ? [$request->query('state')] : EventState::nonTerminalValues(); - $events = $user->events()->with('source')->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get(); - - return response()->json(['events' => $events->map(EventPresenter::forUser(...))->values()]); + return response()->json(['events' => $user->liveEvents($states)->get()->map(EventPresenter::forUser(...))->values()]); } public function ack(Request $request, int $event): JsonResponse diff --git a/core/app/Http/Requests/UpsertEventsRequest.php b/core/app/Http/Requests/UpsertEventsRequest.php index 7c8c48e..078ff5b 100644 --- a/core/app/Http/Requests/UpsertEventsRequest.php +++ b/core/app/Http/Requests/UpsertEventsRequest.php @@ -2,10 +2,9 @@ namespace App\Http\Requests; -use App\Ingest\DueAtParser; +use App\Ingest\EventRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; -use InvalidArgumentException; class UpsertEventsRequest extends FormRequest { @@ -21,42 +20,25 @@ class UpsertEventsRequest extends FormRequest 'events.*.user' => ['required', 'string', 'max:255'], 'events.*.source_ref' => ['required', 'string', 'max:255'], 'events.*.topic' => ['sometimes', 'nullable', 'string', 'max:255'], - 'events.*.due_at' => ['required', 'string'], - 'events.*.fire_at' => ['required', 'date_format:Y-m-d'], - 'events.*.after_due' => ['required', 'in:keep,expire'], - 'events.*.payload' => ['required', 'array'], - 'events.*.payload.title' => ['required', 'string'], - 'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'], - 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?![/\\\\])|https?://)#i'], - 'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], - ]; + ] + EventRules::itemPrefixed('events.*.'); } public function messages(): array { - return [ - 'events.*.payload.deep_link.regex' => 'deep_link должен быть относительным путём или http(s)-ссылкой', - ]; + return EventRules::messages('events.*.'); } public function after(): array { return [function (Validator $v) { foreach ((array) $this->input('events', []) as $i => $item) { - if (! is_array($item) || ! isset($item['due_at'], $item['fire_at']) || ! is_string($item['due_at'])) { + if (! is_array($item)) { continue; } - try { - $due = DueAtParser::parse($item['due_at']); - } catch (InvalidArgumentException $e) { - $v->errors()->add("events.$i.due_at", $e->getMessage()); - - continue; - } - // Проверка fire_at <= день срока — по поясу по умолчанию; для fixed это - // приближение, точный день пользователя известен только при upsert. - if ($v->errors()->missing("events.$i.fire_at") && $item['fire_at'] > $due->dayFor(config('hado.default_tz'))) { - $v->errors()->add("events.$i.fire_at", 'fire_at не может быть позже дня срока'); + foreach (EventRules::check($item) as $field => $error) { + if ($v->errors()->missing("events.$i.$field")) { + $v->errors()->add("events.$i.$field", $error); + } } } }]; diff --git a/core/app/Ingest/EventRules.php b/core/app/Ingest/EventRules.php new file mode 100644 index 0000000..657491d --- /dev/null +++ b/core/app/Ingest/EventRules.php @@ -0,0 +1,97 @@ +> */ + public static function item(): array + { + return [ + 'due_at' => ['required', 'string'], + 'fire_at' => ['required', 'date_format:Y-m-d'], + 'after_due' => ['required', 'in:keep,expire'], + 'payload' => ['required', 'array'], + 'payload.title' => ['required', 'string'], + 'payload.subtitle' => ['sometimes', 'nullable', 'string'], + 'payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?![/\\\\])|https?://)#i'], + 'payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], + ]; + } + + /** + * Те же правила с префиксом ключа — для пачки `events.*.`. + * + * @return array> + */ + public static function itemPrefixed(string $prefix): array + { + $rules = []; + foreach (self::item() as $key => $rule) { + $rules[$prefix.$key] = $rule; + } + + return $rules; + } + + /** @return array */ + public static function messages(string $prefix = ''): array + { + return [$prefix.'payload.deep_link.regex' => 'deep_link должен быть относительным путём или http(s)-ссылкой']; + } + + /** + * Проверки после правил: due_at разбирается, fire_at не позже дня срока. + * Возвращает ошибки как поле => текст; пусто — всё хорошо. + * + * @param array $item + * @return array + */ + public static function check(array $item): array + { + if (! isset($item['due_at'], $item['fire_at']) || ! is_string($item['due_at'])) { + return []; + } + try { + $due = DueAtParser::parse($item['due_at']); + } catch (InvalidArgumentException $e) { + return ['due_at' => $e->getMessage()]; + } + // Проверка fire_at <= день срока — по поясу по умолчанию; для fixed это + // приближение, точный день пользователя известен только при upsert. + if ($item['fire_at'] > $due->dayFor(config('hado.default_tz'))) { + return ['fire_at' => 'fire_at не может быть позже дня срока']; + } + + return []; + } + + /** + * Проверить один элемент целиком. + * + * @param array $item + * + * @throws ValidationException + */ + public static function validate(array $item): void + { + ValidatorFactory::make($item, self::item(), self::messages()) + ->after(function (Validator $v) use ($item) { + foreach (self::check($item) as $field => $error) { + if ($v->errors()->missing($field)) { + $v->errors()->add($field, $error); + } + } + }) + ->validate(); + } +} diff --git a/core/app/Ingest/EventUpserter.php b/core/app/Ingest/EventUpserter.php index 464a732..682ea94 100644 --- a/core/app/Ingest/EventUpserter.php +++ b/core/app/Ingest/EventUpserter.php @@ -38,15 +38,10 @@ final class EventUpserter }); } - public function withdraw(Source $source, string $sourceRef, string $topic): bool + /** Снять событие: withdrawn — терминальное, ядро больше не напоминает. */ + public function withdraw(Event $event): void { - $event = $this->find($source, $sourceRef, $topic); - if ($event === null) { - return false; - } $event->update(['state' => EventState::Withdrawn]); - - return true; } public function find(Source $source, string $sourceRef, string $topic): ?Event diff --git a/core/app/Ingest/ManualEvents.php b/core/app/Ingest/ManualEvents.php index 018e405..d4648c0 100644 --- a/core/app/Ingest/ManualEvents.php +++ b/core/app/Ingest/ManualEvents.php @@ -5,8 +5,10 @@ namespace App\Ingest; use App\Models\Event; use App\Models\Source; use App\Models\User; +use App\Presenters\EventPresenter; use Carbon\CarbonImmutable; use Illuminate\Support\Str; +use LogicException; /** * События, которые пользователь заводит сам — кнопкой в инбоксе или через агента. @@ -32,25 +34,76 @@ final class ManualEvents */ public function create(User $user, array $fields): Event { - return $this->upsert($user, 'manual:'.Str::ulid(), $fields); + return $this->upsert($user, 'manual:'.Str::ulid(), self::item($fields)); } - private function upsert(User $user, string $sourceRef, array $fields): Event + /** + * Частичная правка своего события: не переданные поля берутся из него. Новый `due_at` — + * это перенос (§5): состояние пересчитывается даже из терминального, а `fire_at`, + * если не задан явно, считается заново от нового срока. + * + * @param array $fields + */ + public function update(Event $event, array $fields): Event { - [$result] = $this->upserter->upsert($this->source(), [[ - 'user' => $user->login, - 'source_ref' => $sourceRef, - 'topic' => '', - 'due_at' => $fields['due_at'], - 'fire_at' => $fields['fire_at'] ?? self::defaultFireAt($fields['due_at']), + if (! self::owns($event)) { + throw new LogicException("Событие источника «{$event->source->name}» правит только его источник"); + } + $current = EventPresenter::forClient($event); + $merged = [ + 'title' => $current['payload']['title'], + 'subtitle' => $current['payload']['subtitle'] ?? null, + 'done_label' => $current['payload']['done_label'] ?? null, + 'deep_link' => $current['payload']['deep_link'] ?? null, + 'due_at' => $current['due_at'], + 'fire_at' => isset($fields['due_at']) ? null : $current['fire_at'], + 'after_due' => $current['after_due'], + ]; + foreach ($fields as $key => $value) { + $merged[$key] = $value; + } + + return $this->upsert($event->user, $event->source_ref, self::item($merged)); + } + + public function withdraw(Event $event): void + { + if (! self::owns($event)) { + throw new LogicException("Событие источника «{$event->source->name}» снимает только его источник"); + } + $this->upserter->withdraw($event); + } + + /** + * Плоские поля → upsert-элемент с дефолтами кнопки инбокса. Проверяется теми же + * правилами, что и события клиентов. + * + * @param array $fields + * @return array + */ + private static function item(array $fields): array + { + $dueAt = (string) ($fields['due_at'] ?? ''); + $item = [ + 'due_at' => $dueAt, + 'fire_at' => $fields['fire_at'] ?? self::defaultFireAt($dueAt), 'after_due' => $fields['after_due'] ?? 'keep', 'payload' => array_filter([ - 'title' => $fields['title'], + 'title' => $fields['title'] ?? null, 'subtitle' => $fields['subtitle'] ?? null, 'done_label' => $fields['done_label'] ?? 'Сделано', 'deep_link' => $fields['deep_link'] ?? null, ], fn ($v) => $v !== null && $v !== ''), - ]]); + ]; + EventRules::validate($item); + + return $item; + } + + /** @param array $item */ + private function upsert(User $user, string $sourceRef, array $item): Event + { + [$result] = $this->upserter->upsert($this->source(), [['user' => $user->login, 'source_ref' => $sourceRef, 'topic' => ''] + $item]); return Event::with('source')->findOrFail($result['id']); } @@ -60,9 +113,11 @@ final class ManualEvents return Source::firstOrCreate(['name' => self::SOURCE], ['token_hash' => hash('sha256', Str::random(64))]); } - /** День срока минус DEFAULT_LEAD_DAYS; из due_at берётся только дата. */ - private static function defaultFireAt(string $dueAt): string + /** День срока минус DEFAULT_LEAD_DAYS; из due_at берётся только дата. Кривой due_at отдаём правилам как есть. */ + private static function defaultFireAt(string $dueAt): ?string { - return CarbonImmutable::createFromFormat('Y-m-d', substr($dueAt, 0, 10))->subDays(self::DEFAULT_LEAD_DAYS)->format('Y-m-d'); + $day = CarbonImmutable::createFromFormat('Y-m-d', substr($dueAt, 0, 10)); + + return $day === false ? null : $day->subDays(self::DEFAULT_LEAD_DAYS)->format('Y-m-d'); } } diff --git a/core/app/Mcp/HadoServer.php b/core/app/Mcp/HadoServer.php index fab1d72..8181308 100644 --- a/core/app/Mcp/HadoServer.php +++ b/core/app/Mcp/HadoServer.php @@ -28,5 +28,13 @@ class HadoServer extends Server Повторяющееся — отдельные события на каждое вхождение, не одно с переносом даты. MD; - protected array $tools = []; + protected array $tools = [ + Tools\ListEvents::class, + Tools\GetEvent::class, + Tools\CreateEvent::class, + Tools\UpdateEvent::class, + Tools\DeleteEvent::class, + Tools\MarkDone::class, + Tools\MarkAck::class, + ]; } diff --git a/core/app/Mcp/Tools/AgentTool.php b/core/app/Mcp/Tools/AgentTool.php new file mode 100644 index 0000000..f01935f --- /dev/null +++ b/core/app/Mcp/Tools/AgentTool.php @@ -0,0 +1,50 @@ +attributes->get('user'); + } + + /** Событие по `id` из аргументов; чужое или несуществующее — ошибка для модели, а не 500. */ + protected function withEvent(Request $request, Closure $then): Response|ResponseFactory + { + $id = (int) $request->validate(['id' => ['required', 'integer']])['id']; + try { + $event = $this->user()->ownEvent($id); + } catch (ModelNotFoundException) { + return Response::error('Событие не найдено'); + } + + return $then($event); + } + + /** То же, но только своё (manual) событие — для правки и снятия. */ + protected function withOwnEvent(Request $request, Closure $then): Response|ResponseFactory + { + return $this->withEvent($request, fn (Event $event) => ManualEvents::owns($event) + ? $then($event) + : Response::error("Это событие источника {$event->source->name}: правит и снимает его только источник. Можно закрыть через mark_done.")); + } + + /** @param array $data */ + protected function respond(array $data): ResponseFactory + { + return Response::structured($data); + } +} diff --git a/core/app/Mcp/Tools/CreateEvent.php b/core/app/Mcp/Tools/CreateEvent.php new file mode 100644 index 0000000..54dbcd9 --- /dev/null +++ b/core/app/Mcp/Tools/CreateEvent.php @@ -0,0 +1,72 @@ +validate(self::shape(required: true)); + + return $this->respond(EventPresenter::forUser($this->manual->create($this->user(), $fields))); + } + + /** + * Поля события — общие для create_event и update_event. Форматы и смысл + * (fire_at не позже срока, deep_link — путь или ссылка) проверяет ManualEvents. + * + * @return array + */ + public static function fields(JsonSchema $schema, bool $required): array + { + $title = $schema->string()->max(200)->description('Заголовок напоминания'); + $dueAt = $schema->string()->description('Срок: YYYY-MM-DD, YYYY-MM-DDTHH:MM или со смещением'); + + return [ + 'title' => $required ? $title->required() : $title, + 'due_at' => $required ? $dueAt->required() : $dueAt, + 'subtitle' => $schema->string()->max(200)->description('Подзаголовок'), + 'fire_at' => $schema->string()->description('YYYY-MM-DD, день начала подготовки; не позже дня срока'), + 'after_due' => $schema->string()->enum(['keep', 'expire'])->description('Что после срока: keep (по умолчанию) или expire'), + 'done_label' => $schema->string()->max(64)->description('Подпись кнопки закрытия, по умолчанию «Сделано»'), + 'deep_link' => $schema->string()->description('Куда вести из напоминания: относительный путь или http(s)-ссылка'), + ]; + } + + /** + * Форма аргументов: что передано и какого типа. Остальное — EventRules через ManualEvents. + * + * @return array> + */ + public static function shape(bool $required): array + { + $presence = $required ? 'required' : 'sometimes'; + + return [ + 'title' => [$presence, 'string', 'max:200'], + 'due_at' => [$presence, 'string'], + 'subtitle' => ['sometimes', 'nullable', 'string', 'max:200'], + 'fire_at' => ['sometimes', 'nullable', 'string'], + 'after_due' => ['sometimes', 'string'], + 'done_label' => ['sometimes', 'nullable', 'string'], + 'deep_link' => ['sometimes', 'nullable', 'string'], + ]; + } +} diff --git a/core/app/Mcp/Tools/DeleteEvent.php b/core/app/Mcp/Tools/DeleteEvent.php new file mode 100644 index 0000000..55d07f6 --- /dev/null +++ b/core/app/Mcp/Tools/DeleteEvent.php @@ -0,0 +1,35 @@ + $schema->integer()->required()]; + } + + public function handle(Request $request): Response|ResponseFactory + { + return $this->withOwnEvent($request, function (Event $event) { + $this->manual->withdraw($event); + + return $this->respond(['id' => $event->id, 'state' => $event->fresh()->state->value]); + }); + } +} diff --git a/core/app/Mcp/Tools/GetEvent.php b/core/app/Mcp/Tools/GetEvent.php new file mode 100644 index 0000000..865f927 --- /dev/null +++ b/core/app/Mcp/Tools/GetEvent.php @@ -0,0 +1,29 @@ + $schema->integer()->required()]; + } + + public function handle(Request $request): Response|ResponseFactory + { + return $this->withEvent($request, fn (Event $event) => $this->respond(EventPresenter::forUser($event))); + } +} diff --git a/core/app/Mcp/Tools/ListEvents.php b/core/app/Mcp/Tools/ListEvents.php new file mode 100644 index 0000000..7345316 --- /dev/null +++ b/core/app/Mcp/Tools/ListEvents.php @@ -0,0 +1,46 @@ + $schema->string()->enum(EventState::nonTerminalValues())->description('Только события в этом состоянии'), + 'archive' => $schema->boolean()->description('Закрытые события вместо активных'), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $args = $request->validate([ + 'state' => ['sometimes', Rule::in(EventState::nonTerminalValues())], + 'archive' => ['sometimes', 'boolean'], + ]); + $user = $this->user(); + + if ($args['archive'] ?? false) { + return $this->respond(['events' => $user->archive()->get() + ->map(fn (Event $e) => EventPresenter::forUser($e) + ['closed_as' => EventPresenter::closedAs($e)])->values()->all()]); + } + + $states = isset($args['state']) ? [$args['state']] : EventState::nonTerminalValues(); + + return $this->respond(['events' => $user->liveEvents($states)->get()->map(EventPresenter::forUser(...))->values()->all()]); + } +} diff --git a/core/app/Mcp/Tools/MarkAck.php b/core/app/Mcp/Tools/MarkAck.php new file mode 100644 index 0000000..cfa500c --- /dev/null +++ b/core/app/Mcp/Tools/MarkAck.php @@ -0,0 +1,34 @@ + $schema->integer()->required()]; + } + + public function handle(Request $request): Response|ResponseFactory + { + return $this->withEvent($request, function (Event $event) { + $this->actions->ack($event); + + return $this->respond(EventPresenter::forUser($event->fresh('source'))); + }); + } +} diff --git a/core/app/Mcp/Tools/MarkDone.php b/core/app/Mcp/Tools/MarkDone.php new file mode 100644 index 0000000..9571fea --- /dev/null +++ b/core/app/Mcp/Tools/MarkDone.php @@ -0,0 +1,34 @@ + $schema->integer()->required()]; + } + + public function handle(Request $request): Response|ResponseFactory + { + return $this->withEvent($request, function (Event $event) { + $this->actions->done($event); + + return $this->respond(EventPresenter::forUser($event->fresh('source'))); + }); + } +} diff --git a/core/app/Mcp/Tools/UpdateEvent.php b/core/app/Mcp/Tools/UpdateEvent.php new file mode 100644 index 0000000..d5ad83b --- /dev/null +++ b/core/app/Mcp/Tools/UpdateEvent.php @@ -0,0 +1,32 @@ + $schema->integer()->required()] + CreateEvent::fields($schema, required: false); + } + + public function handle(Request $request): Response|ResponseFactory + { + $fields = $request->validate(CreateEvent::shape(required: false)); + + return $this->withOwnEvent($request, fn (Event $event) => $this->respond(EventPresenter::forUser($this->manual->update($event, $fields)))); + } +} diff --git a/core/app/Models/User.php b/core/app/Models/User.php index 7c5a08a..be60d89 100644 --- a/core/app/Models/User.php +++ b/core/app/Models/User.php @@ -27,6 +27,16 @@ class User extends Model return $this->events()->with('source')->whereKey($id)->firstOrFail(); } + /** + * Живые события в этих состояниях, по сроку. + * + * @param list $states + */ + public function liveEvents(array $states): HasMany + { + return $this->events()->with('source')->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant'); + } + /** Закрытые события для архива: done и expired, свежие первыми, не больше 100. */ public function archive(): HasMany { diff --git a/core/tests/Feature/EventUpserterTest.php b/core/tests/Feature/EventUpserterTest.php index 14895bc..e820221 100644 --- a/core/tests/Feature/EventUpserterTest.php +++ b/core/tests/Feature/EventUpserterTest.php @@ -126,12 +126,13 @@ class EventUpserterTest extends TestCase $this->assertNull($event->due_date); } - public function test_withdraw_marks_withdrawn_and_reports_missing(): void + public function test_withdraw_marks_withdrawn(): void { $this->upserter->upsert($this->source, [$this->item()]); - $this->assertTrue($this->upserter->withdraw($this->source, 'document:918', 'expiry')); + $this->upserter->withdraw($this->upserter->find($this->source, 'document:918', 'expiry')); + $this->assertSame(EventState::Withdrawn, Event::sole()->state); - $this->assertFalse($this->upserter->withdraw($this->source, 'document:918', 'other')); + $this->assertNull($this->upserter->find($this->source, 'document:918', 'other')); } } diff --git a/core/tests/Feature/McpToolsTest.php b/core/tests/Feature/McpToolsTest.php new file mode 100644 index 0000000..bcfd83f --- /dev/null +++ b/core/tests/Feature/McpToolsTest.php @@ -0,0 +1,179 @@ + 'UTC']); + CarbonImmutable::setTestNow('2026-09-05T12:00:00Z'); + $this->user = User::create(['login' => 'nikita', 'tz' => 'UTC']); + $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_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); + } +}