'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_deep_link_must_be_relative_or_http(): void { $bad = $this->item(['payload' => ['title' => 'x', 'deep_link' => 'javascript:alert(1)']]); $this->putJson('/api/events', ['events' => [$bad]], $this->auth()) ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); $protocolRelative = $this->item(['payload' => ['title' => 'x', 'deep_link' => '//evil.com/x']]); $this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth()) ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); $ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]); $this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk(); $ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]); $this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk(); } 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(); } }