feat: web, webhook and telegram channel drivers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
91
core/tests/Unit/Channels/TelegramChannelTest.php
Normal file
91
core/tests/Unit/Channels/TelegramChannelTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Channels;
|
||||
|
||||
use App\Channels\TelegramChannel;
|
||||
use App\Enums\AfterDue;
|
||||
use App\Enums\DueMode;
|
||||
use App\Enums\EventState;
|
||||
use App\Enums\Presence;
|
||||
use App\Models\Delivery;
|
||||
use App\Models\Event;
|
||||
use App\Models\Source;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TelegramChannelTest extends TestCase
|
||||
{
|
||||
private function driver(): TelegramChannel
|
||||
{
|
||||
return new TelegramChannel('test-token', 600);
|
||||
}
|
||||
|
||||
private function event(): Event
|
||||
{
|
||||
$event = new Event([
|
||||
'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14',
|
||||
'after_due' => AfterDue::Expire, 'state' => EventState::Today,
|
||||
'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'done_label' => 'Поздравил'],
|
||||
]);
|
||||
$event->id = 17;
|
||||
$event->setRelation('source', new Source(['name' => 'docs']));
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
public function test_presence_is_unknown_unless_recently_seen(): void
|
||||
{
|
||||
$user = new User(['login' => 'n', 'tz' => 'UTC']);
|
||||
$this->assertSame(Presence::Unknown, $this->driver()->presence($user, ['chat_id' => 555]));
|
||||
|
||||
$this->driver()->touch(555);
|
||||
$this->assertSame(Presence::Present, $this->driver()->presence($user, ['chat_id' => 555]));
|
||||
}
|
||||
|
||||
public function test_deliver_sends_message_with_two_buttons_and_stores_message_id(): void
|
||||
{
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => ['message_id' => 9001]])]);
|
||||
|
||||
$outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok']));
|
||||
|
||||
$this->assertTrue($outcome->ok);
|
||||
$this->assertSame(['message_id' => 9001], $outcome->meta);
|
||||
Http::assertSent(function (Request $r) {
|
||||
$kb = $r['reply_markup']['inline_keyboard'][0];
|
||||
|
||||
return str_ends_with($r->url(), '/bottest-token/sendMessage')
|
||||
&& $r['chat_id'] === 555
|
||||
&& str_contains($r['text'], 'Сегодня ДР — Вася')
|
||||
&& str_contains($r['text'], 'исполняется 34')
|
||||
&& $kb[0] === ['text' => 'Помню', 'callback_data' => 'ack:tok']
|
||||
&& $kb[1] === ['text' => 'Поздравил', 'callback_data' => 'done:tok'];
|
||||
});
|
||||
}
|
||||
|
||||
public function test_deliver_fails_on_api_error(): void
|
||||
{
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400)]);
|
||||
|
||||
$outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok']));
|
||||
|
||||
$this->assertFalse($outcome->ok);
|
||||
$this->assertStringContainsString('chat not found', $outcome->error);
|
||||
}
|
||||
|
||||
public function test_on_done_clears_buttons_best_effort(): void
|
||||
{
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true])]);
|
||||
$delivery = new Delivery(['action_token' => 'tok', 'meta' => ['message_id' => 9001]]);
|
||||
$delivery->setRelation('channel', new \App\Models\Channel(['config' => ['chat_id' => 555]]));
|
||||
|
||||
$this->driver()->onDone($this->event(), $delivery);
|
||||
|
||||
Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001 && $r['chat_id'] === 555);
|
||||
|
||||
Http::fake(fn () => throw new \RuntimeException('down'));
|
||||
$this->driver()->onDone($this->event(), $delivery); // не бросает
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
23
core/tests/Unit/Channels/WebChannelTest.php
Normal file
23
core/tests/Unit/Channels/WebChannelTest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Channels;
|
||||
|
||||
use App\Channels\WebChannel;
|
||||
use App\Enums\Presence;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebChannelTest extends TestCase
|
||||
{
|
||||
public function test_presence_follows_heartbeat(): void
|
||||
{
|
||||
$user = new User(['login' => 'n', 'tz' => 'UTC']);
|
||||
$user->id = 7;
|
||||
$driver = new WebChannel;
|
||||
|
||||
$this->assertSame(Presence::Absent, $driver->presence($user, []));
|
||||
Cache::put('heartbeat:7', true, 90);
|
||||
$this->assertSame(Presence::Present, $driver->presence($user, []));
|
||||
}
|
||||
}
|
||||
103
core/tests/Unit/Channels/WebhookChannelTest.php
Normal file
103
core/tests/Unit/Channels/WebhookChannelTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Channels;
|
||||
|
||||
use App\Channels\WebhookChannel;
|
||||
use App\Enums\AfterDue;
|
||||
use App\Enums\DueMode;
|
||||
use App\Enums\EventState;
|
||||
use App\Enums\Presence;
|
||||
use App\Models\Delivery;
|
||||
use App\Models\Event;
|
||||
use App\Models\Source;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookChannelTest extends TestCase
|
||||
{
|
||||
private array $config = ['deliver_url' => 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/hado/presence'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Route::post('/a/{token}/ack', fn () => '')->name('action.ack');
|
||||
Route::post('/a/{token}/done', fn () => '')->name('action.done');
|
||||
// Laravel only refreshes the named-route lookup table once, right after the app's own
|
||||
// route files finish loading during boot. Routes added later (e.g. here in setUp) are
|
||||
// appended to the collection but never indexed by name unless we refresh explicitly,
|
||||
// so route('action.ack', ...) would otherwise throw RouteNotFoundException. This does
|
||||
// not affect production: Task 10's real routes live in the route files that already
|
||||
// get refreshed automatically.
|
||||
Route::getRoutes()->refreshNameLookups();
|
||||
}
|
||||
|
||||
private function driver(): WebhookChannel
|
||||
{
|
||||
return new WebhookChannel(2);
|
||||
}
|
||||
|
||||
public function test_presence_from_json(): void
|
||||
{
|
||||
Http::fake([
|
||||
'ha.test/api/hado/presence' => Http::sequence()
|
||||
->push(['present' => true])
|
||||
->push(['present' => false])
|
||||
->push('oops', 500),
|
||||
]);
|
||||
$user = new User(['login' => 'n', 'tz' => 'UTC']);
|
||||
|
||||
$this->assertSame(Presence::Present, $this->driver()->presence($user, $this->config));
|
||||
$this->assertSame(Presence::Absent, $this->driver()->presence($user, $this->config));
|
||||
$this->assertSame(Presence::Unknown, $this->driver()->presence($user, $this->config));
|
||||
}
|
||||
|
||||
public function test_presence_unknown_on_timeout(): void
|
||||
{
|
||||
Http::fake(fn () => throw new ConnectionException('timeout'));
|
||||
$this->assertSame(Presence::Unknown, $this->driver()->presence(new User(['login' => 'n', 'tz' => 'UTC']), $this->config));
|
||||
}
|
||||
|
||||
public function test_deliver_posts_event_and_action_urls(): void
|
||||
{
|
||||
Http::fake(['ha.test/api/webhook/hado' => Http::response('', 200)]);
|
||||
|
||||
$event = new Event([
|
||||
'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14',
|
||||
'after_due' => AfterDue::Expire, 'state' => EventState::Today,
|
||||
'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'deep_link' => '/people/42', 'done_label' => 'Поздравил'],
|
||||
]);
|
||||
$event->id = 17;
|
||||
$event->setRelation('source', new Source(['name' => 'docs']));
|
||||
$delivery = new Delivery(['action_token' => 'tok123']);
|
||||
|
||||
$outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, $delivery);
|
||||
|
||||
$this->assertTrue($outcome->ok);
|
||||
Http::assertSent(function (Request $r) {
|
||||
return $r->url() === 'https://ha.test/api/webhook/hado'
|
||||
&& $r['event']['id'] === 17
|
||||
&& $r['event']['title'] === 'Сегодня ДР — Вася'
|
||||
&& $r['event']['done_label'] === 'Поздравил'
|
||||
&& $r['event']['source'] === 'docs'
|
||||
&& $r['event']['due_at'] === '2026-08-14'
|
||||
&& str_ends_with($r['actions']['ack'], '/a/tok123/ack')
|
||||
&& str_ends_with($r['actions']['done'], '/a/tok123/done');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_deliver_reports_failure(): void
|
||||
{
|
||||
Http::fake(['ha.test/api/webhook/hado' => Http::response('nope', 503)]);
|
||||
$event = new Event(['due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', 'after_due' => AfterDue::Expire, 'state' => EventState::Today, 'payload' => ['title' => 't']]);
|
||||
$event->setRelation('source', new Source(['name' => 'docs']));
|
||||
|
||||
$outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, new Delivery(['action_token' => 'x']));
|
||||
|
||||
$this->assertFalse($outcome->ok);
|
||||
$this->assertStringContainsString('503', $outcome->error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user