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>
This commit is contained in:
218
tests/Feature/Api/ContentViewsTest.php
Normal file
218
tests/Feature/Api/ContentViewsTest.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ContentView;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* What the frontend read, reported back.
|
||||
*
|
||||
* Two things need holding still. The first is that this stays a tally: one row
|
||||
* per record, per language, per day, however many reports arrive. The second
|
||||
* is what it refuses to collect — the moment a visitor could be reconstructed
|
||||
* out of this table it has become something nobody agreed to run.
|
||||
*/
|
||||
class ContentViewsTest 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/views', $payload);
|
||||
}
|
||||
|
||||
protected function page(string $slug = 'quienes-somos'): Page
|
||||
{
|
||||
return Page::factory()->create([
|
||||
'slug' => ['es' => $slug, 'en' => "{$slug}-en", 'ca' => "{$slug}-ca"],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_counts_a_read(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 1)
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
|
||||
$this->assertDatabaseHas('content_views', [
|
||||
'viewable_type' => $page->getMorphClass(),
|
||||
'viewable_id' => $page->id,
|
||||
'locale' => 'es',
|
||||
'count' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property that keeps this from becoming a log.
|
||||
*/
|
||||
#[Test]
|
||||
public function reports_for_the_same_day_add_up_in_one_row(): void
|
||||
{
|
||||
$this->page();
|
||||
|
||||
foreach (range(1, 4) as $ignored) {
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
|
||||
}
|
||||
|
||||
$this->assertSame(1, ContentView::query()->count());
|
||||
$this->assertSame(4, (int) ContentView::query()->first()->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_batch_can_carry_its_own_counts(): void
|
||||
{
|
||||
$this->page();
|
||||
Project::factory()->create(['slug' => ['es' => 'mi-web', 'en' => 'my-web', 'ca' => 'el-meu-web']]);
|
||||
|
||||
$this->report(['views' => [
|
||||
['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es', 'count' => 12],
|
||||
['type' => 'project', 'slug' => 'mi-web', 'locale' => 'es', 'count' => 3],
|
||||
]])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 2);
|
||||
|
||||
$this->assertSame(15, (int) ContentView::query()->sum('count'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same record read in two languages is two rows: which language people
|
||||
* read something in is most of what the figures are for.
|
||||
*/
|
||||
#[Test]
|
||||
public function each_language_is_counted_separately(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos-ca', 'locale' => 'ca'])->assertAccepted();
|
||||
|
||||
$this->assertSame(2, ContentView::query()->count());
|
||||
$this->assertSame(2, $page->viewsSince(30));
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug that no longer resolves is a 404, and the endpoint next door
|
||||
* exists for those. Counting it here would attach a read to nothing.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_slug_that_resolves_to_nothing_is_not_counted(): void
|
||||
{
|
||||
$this->report(['type' => 'page', 'slug' => 'no-existe', 'locale' => 'es'])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.received', 1)
|
||||
->assertJsonPath('data.recorded', 0);
|
||||
|
||||
$this->assertSame(0, ContentView::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_without_the_secret(): void
|
||||
{
|
||||
$this->page();
|
||||
|
||||
$this->postJson('/api/public/v1/telemetry/views', ['type' => 'page', 'slug' => 'quienes-somos'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame(0, ContentView::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_what_it_cannot_use(): void
|
||||
{
|
||||
$this->report(['type' => 'invento', 'slug' => 'x'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('views.0.type');
|
||||
|
||||
// The frontend counts its own renders; it does not get to invent an
|
||||
// audience of any size it likes.
|
||||
$this->page();
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'count' => 999999])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('views.0.count');
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole privacy argument for this table, as a test: there is nowhere
|
||||
* to put an address, a session or a time of day, so no future change can
|
||||
* quietly start keeping one.
|
||||
*/
|
||||
#[Test]
|
||||
public function nothing_about_a_visitor_is_stored(): void
|
||||
{
|
||||
$this->page();
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$columns = array_keys((array) ContentView::query()->first()->getAttributes());
|
||||
|
||||
$this->assertSame([
|
||||
'id', 'viewable_type', 'viewable_id', 'locale', 'day', 'count',
|
||||
'created_at', 'updated_at',
|
||||
], $columns);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_listing_figure_comes_from_one_query(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
ContentView::record($page, 'es', 5);
|
||||
ContentView::record($page, 'ca', 2);
|
||||
|
||||
$row = Page::query()->withRecentViews()->find($page->id);
|
||||
|
||||
$this->assertSame(7, (int) $row->recent_views);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function figures_older_than_the_window_do_not_count_as_recent(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$page->views()->create([
|
||||
'locale' => 'es',
|
||||
'day' => now()->subDays(90)->toDateString(),
|
||||
'count' => 40,
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $page->viewsSince(30));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pruning_forgets_figures_nobody_compares_against(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$page->views()->create(['locale' => 'es', 'day' => now()->subDays(500)->toDateString(), 'count' => 9]);
|
||||
$page->views()->create(['locale' => 'es', 'day' => now()->subDays(10)->toDateString(), 'count' => 3]);
|
||||
|
||||
$this->artisan('content:prune')->assertExitCode(0);
|
||||
|
||||
$this->assertSame(1, ContentView::query()->count());
|
||||
$this->assertSame(3, (int) ContentView::query()->first()->count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user