Files
landing-api/tests/Feature/Api/TelemetryApiTest.php
Josep Piguillem 3897113313 Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:55:27 +02:00

302 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\MissingUrlStatus;
use App\Models\MissingUrl;
use App\Models\Redirect;
use App\Support\Maintenance;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The frontend reporting what it could not resolve.
*
* The endpoint is small; what needs holding still is everything around it. It
* is a write endpoint on the public API, so it is gated by a secret and can
* never be open by accident. It is fed by strangers' traffic, so a scan must
* not be able to turn the table into a log. And what it collects is only worth
* collecting if it lines up exactly with what a redirect matches against.
*/
class TelemetryApiTest extends TestCase
{
use RefreshDatabase;
protected string $token = 'ingest-secret';
protected function setUp(): void
{
parent::setUp();
config(['site.telemetry.token' => $this->token]);
$this->withHeaders(['Accept-Language' => 'es']);
}
/**
* @param array<string, mixed> $payload
*/
protected function report(array $payload): TestResponse
{
return $this->withHeaders(['X-Ingest-Token' => $this->token])
->postJson('/api/public/v1/telemetry/not-found', $payload);
}
#[Test]
public function it_records_an_address_that_could_not_be_resolved(): void
{
$this->report([
'path' => '/proyectos/nombre-viejo',
'locale' => 'es',
'referrer' => 'https://example.net/enlace',
])
->assertAccepted()
->assertJsonPath('data.recorded', 1)
// A confirmation is nobody else's, and this one is not cacheable
// for the same reason a submission's is not.
->assertHeader('Cache-Control', 'no-store, private');
$this->assertDatabaseHas('missing_urls', [
'path' => '/proyectos/nombre-viejo',
'locale' => 'es',
'hits' => 1,
'referrer' => 'https://example.net/enlace',
'status' => MissingUrlStatus::Pending->value,
]);
}
/**
* The property that makes this a counter rather than a log: a crawler
* walking a dead section must cost one row, not one row per request.
*/
#[Test]
public function reporting_the_same_address_again_counts_instead_of_repeating(): void
{
foreach (range(1, 3) as $ignored) {
$this->report(['path' => '/equipo/quien-sea', 'locale' => 'es'])->assertAccepted();
}
$this->assertSame(1, MissingUrl::query()->count());
$this->assertSame(3, (int) MissingUrl::query()->first()->hits);
}
/**
* Paths are normalised exactly as redirects are. If these two ever
* disagree, the list fills with addresses that already have a rule.
*/
#[Test]
public function it_normalises_the_path_the_way_a_redirect_does(): void
{
$this->report(['path' => '/proyectos/algo/', 'locale' => 'es'])->assertAccepted();
$this->report(['path' => '/proyectos/algo?utm_source=twitter', 'locale' => 'es'])->assertAccepted();
$this->assertSame(1, MissingUrl::query()->count());
$this->assertSame('/proyectos/algo', MissingUrl::query()->first()->path);
}
#[Test]
public function it_accepts_a_batch(): void
{
$this->report(['hits' => [
['path' => '/uno', 'locale' => 'es'],
['path' => '/dos', 'locale' => 'en'],
['path' => '/tres'],
]])
->assertAccepted()
->assertJsonPath('data.recorded', 3);
$this->assertSame(3, MissingUrl::query()->count());
}
/**
* The locale is half of the unique key, so it can never be null — the
* negotiated one stands in when the frontend does not say.
*/
#[Test]
public function a_report_without_a_locale_falls_back_to_the_negotiated_one(): void
{
$this->withHeaders(['Accept-Language' => 'ca'])
->report(['path' => '/sense-idioma'])
->assertAccepted();
$this->assertDatabaseHas('missing_urls', ['path' => '/sense-idioma', 'locale' => 'ca']);
}
/**
* The same address in two languages is two problems: they are fixed by two
* different redirects, because the paths differ per language.
*/
#[Test]
public function the_same_path_in_two_languages_is_two_rows(): void
{
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
$this->assertSame(2, MissingUrl::query()->count());
}
#[Test]
public function it_drops_scans_for_things_that_were_never_here(): void
{
$this->report(['hits' => [
['path' => '/wp-admin/setup-config.php'],
['path' => '/.env'],
['path' => '/proyectos/de-verdad'],
]])
->assertAccepted()
->assertJsonPath('data.received', 3)
// Reported honestly: the frontend can tell its filter from ours.
->assertJsonPath('data.recorded', 1);
$this->assertSame(1, MissingUrl::query()->count());
$this->assertSame('/proyectos/de-verdad', MissingUrl::query()->first()->path);
}
/**
* A later sighting without a referrer is what a bot produces. It must not
* erase the referrer that told us who is linking to a dead address.
*/
#[Test]
public function a_later_sighting_never_erases_a_known_referrer(): void
{
$this->report(['path' => '/algo', 'locale' => 'es', 'referrer' => 'https://example.net/post'])
->assertAccepted();
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
$this->assertSame('https://example.net/post', MissingUrl::query()->first()->referrer);
}
#[Test]
public function it_refuses_without_the_secret(): void
{
$this->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
->assertForbidden();
$this->withHeaders(['X-Ingest-Token' => 'wrong'])
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
->assertForbidden();
$this->assertSame(0, MissingUrl::query()->count());
}
/**
* The rule the preview secret already follows: an unset secret disables
* the endpoint, it never opens it.
*/
#[Test]
public function an_unset_secret_grants_nobody_access(): void
{
config(['site.telemetry.token' => null]);
$this->withHeaders(['X-Ingest-Token' => ''])
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
->assertForbidden();
$this->assertSame(0, MissingUrl::query()->count());
}
#[Test]
public function it_rejects_what_it_cannot_use(): void
{
$this->report(['path' => 'https://otro-dominio.example/algo'])
->assertUnprocessable()
->assertJsonValidationErrors('hits.0.path');
$this->report(['hits' => []])->assertUnprocessable();
$this->report(['path' => '/algo', 'locale' => 'de'])
->assertUnprocessable()
->assertJsonValidationErrors('hits.0.locale');
}
#[Test]
public function it_caps_how_much_one_request_may_carry(): void
{
config(['site.telemetry.max_batch' => 2]);
$this->report(['hits' => [
['path' => '/uno'], ['path' => '/dos'], ['path' => '/tres'],
]])->assertUnprocessable();
}
/**
* Maintenance is about what visitors are shown. A 404 during it is still a
* 404 — and it is the most likely moment to produce the interesting ones.
*/
#[Test]
public function it_keeps_listening_while_the_site_is_down_for_maintenance(): void
{
app(Maintenance::class)->enable();
$this->report(['path' => '/durante-el-mantenimiento', 'locale' => 'es'])
->assertAccepted();
$this->assertDatabaseHas('missing_urls', ['path' => '/durante-el-mantenimiento']);
}
/**
* The loop this feature exists to close: a report becomes a redirect, and
* the report stops asking to be dealt with.
*/
#[Test]
public function writing_a_redirect_resolves_the_report_it_answers(): void
{
$this->report(['path' => '/proyectos/viejo', 'locale' => 'es'])->assertAccepted();
Redirect::create([
'from' => '/proyectos/viejo',
'to' => '/proyectos/nuevo',
'status' => 301,
'locale' => 'es',
'is_enabled' => true,
]);
$this->assertSame(
MissingUrlStatus::Resolved,
MissingUrl::query()->first()->status,
);
}
#[Test]
public function a_redirect_for_every_language_resolves_every_languages_report(): void
{
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
Redirect::create([
'from' => '/algo',
'to' => '/otra-cosa',
'status' => 301,
'locale' => null,
'is_enabled' => true,
]);
$this->assertSame(0, MissingUrl::query()->pending()->count());
}
/**
* A redirect that was written and then deleted leaves the address broken
* again. Being told about it once must not mean being told never again.
*/
#[Test]
public function an_address_seen_again_after_being_dealt_with_comes_back(): void
{
$url = MissingUrl::factory()->resolved()->create([
'path' => '/vuelve',
'locale' => 'es',
]);
$this->report(['path' => '/vuelve', 'locale' => 'es'])->assertAccepted();
$url->refresh();
$this->assertSame(MissingUrlStatus::Pending, $url->status);
$this->assertNull($url->resolved_at);
}
}