$this->token]); $this->withHeaders(['Accept-Language' => 'es']); } /** * @param array $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); } }