feat: source tokens and client events API

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 06:58:03 -03:00
parent 245ca88af6
commit 5ea5c045a7
9 changed files with 387 additions and 2 deletions

View File

@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ClientApiTest extends TestCase
{
use RefreshDatabase;
private const TOKEN = 'hado_testtoken';
protected function setUp(): void
{
parent::setUp();
CarbonImmutable::setTestNow('2026-09-01T12:00:00Z');
Source::create(['name' => 'docs', 'token_hash' => hash('sha256', self::TOKEN)]);
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
private function item(array $over = []): array
{
return array_replace([
'user' => 'nikita', 'source_ref' => 'person:42', 'topic' => 'birthday:2026',
'due_at' => '2026-08-14', 'fire_at' => '2026-08-14', 'after_due' => 'expire',
'payload' => ['title' => 'Сегодня ДР — Вася', 'deep_link' => '/people/42'],
], $over);
}
private function auth(): array
{
return ['Authorization' => 'Bearer '.self::TOKEN];
}
public function test_rejects_missing_or_wrong_token(): void
{
$this->putJson('/api/events', ['events' => [$this->item()]])->assertStatus(401);
$this->putJson('/api/events', ['events' => [$this->item()]], ['Authorization' => 'Bearer nope'])->assertStatus(401);
}
public function test_upserts_batch_and_returns_ids_and_states(): void
{
$this->putJson('/api/events', ['events' => [$this->item()]], $this->auth())
->assertOk()
->assertJsonPath('events.0.source_ref', 'person:42')
->assertJsonPath('events.0.topic', 'birthday:2026')
->assertJsonPath('events.0.state', 'expired');
$this->assertSame(1, Event::count());
}
public function test_validation_rejects_whole_batch_with_index(): void
{
$bad = $this->item(['due_at' => 'tomorrow']);
$this->putJson('/api/events', ['events' => [$this->item(), $bad]], $this->auth())
->assertStatus(422)
->assertJsonValidationErrors(['events.1.due_at']);
$this->assertSame(0, Event::count(), 'ничего не применилось');
}
public function test_validation_rules(): void
{
$this->putJson('/api/events', ['events' => [$this->item(['fire_at' => '2026-08-15'])]], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events.0.fire_at']);
$this->putJson('/api/events', ['events' => [$this->item(['after_due' => 'maybe'])]], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events.0.after_due']);
$this->putJson('/api/events', ['events' => [$this->item(['payload' => ['subtitle' => 'x']])]], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.title']);
$this->putJson('/api/events', ['events' => [$this->item(['user' => ''])]], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events.0.user']);
$this->putJson('/api/events', ['events' => array_fill(0, 501, $this->item())], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events']);
}
public function test_topic_defaults_to_empty_string(): void
{
$item = $this->item();
unset($item['topic']);
$this->putJson('/api/events', ['events' => [$item]], $this->auth())->assertOk()->assertJsonPath('events.0.topic', '');
}
public function test_get_and_delete_by_identity(): void
{
$this->putJson('/api/events', ['events' => [$this->item(['due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep'])]], $this->auth());
$this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', $this->auth())
->assertOk()
->assertJsonPath('state', 'preparing')
->assertJsonPath('due_at', '2026-09-30')
->assertJsonPath('topic', 'birthday:2026');
$this->deleteJson('/api/events?source_ref=person:42&topic=birthday:2026', [], $this->auth())->assertNoContent();
$this->assertSame(EventState::Withdrawn, Event::sole()->state);
$this->getJson('/api/events?source_ref=person:42&topic=nope', $this->auth())->assertNotFound();
$this->deleteJson('/api/events?source_ref=person:42&topic=nope', [], $this->auth())->assertNotFound();
}
public function test_source_is_isolated_by_token(): void
{
Source::create(['name' => 'other', 'token_hash' => hash('sha256', 'other-token')]);
$this->putJson('/api/events', ['events' => [$this->item()]], $this->auth());
$this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', ['Authorization' => 'Bearer other-token'])->assertNotFound();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Tests\Feature;
use App\Models\Source;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SourceCreateCommandTest extends TestCase
{
use RefreshDatabase;
public function test_creates_source_and_prints_token_once(): void
{
$this->artisan('hado:source:create', ['name' => 'docs'])
->expectsOutputToContain('Token: hado_')
->assertSuccessful();
$source = Source::where('name', 'docs')->sole();
$this->assertSame(64, strlen($source->token_hash));
}
public function test_refuses_duplicate_name(): void
{
Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
$this->artisan('hado:source:create', ['name' => 'docs'])->assertFailed();
}
}