Tag blog posts and sign them with a member
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:01:18 +02:00
parent 6fc600e6a1
commit 9f970808ca
27 changed files with 809 additions and 12 deletions

View File

@@ -19,6 +19,7 @@ use App\Models\ReplyTemplate;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Setting;
use App\Models\Tag;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
@@ -118,6 +119,7 @@ class AdminApiContractTest extends TestCase
$application = Application::factory()->for($call)->create();
$application->replies()->create(['user_id' => $this->caller->id, 'user_name' => $this->caller->name, 'subject' => 'Re', 'body' => 'Hola', 'channel' => 'mail']);
ReplyTemplate::factory()->create();
$page->tags()->attach(Tag::factory()->create());
$redirect = Redirect::factory()->create();
$missingUrl = MissingUrl::factory()->create();
$repository = Repository::factory()->create();
@@ -158,6 +160,7 @@ class AdminApiContractTest extends TestCase
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
'replies' => ["/v1/applications/{$application->id}/replies", '/v1/applications/{application}/replies'],
'reply templates' => ['/v1/reply-templates', '/v1/reply-templates'],
'tags' => ['/v1/tags', '/v1/tags'],
'redirects' => ['/v1/redirects', '/v1/redirects'],
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Filament\Resources\Pages\Pages\EditPage;
use App\Models\Member;
use App\Models\Page;
use App\Models\Tag;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Tags and bylines for the blog: written in the panel or the API, read by the
* public site.
*/
class BlogTagsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
}
protected function tag(string $es, string $en): Tag
{
return Tag::factory()->create([
'name' => ['es' => ucfirst($es), 'en' => ucfirst($en)],
'slug' => ['es' => $es, 'en' => $en],
]);
}
#[Test]
public function a_post_carries_its_tags_and_who_signs_it(): void
{
$ada = Member::factory()->create(['name' => 'Ada', 'slug' => 'ada', 'is_public' => true]);
$page = Page::factory()->published()->create([
'slug' => ['es' => 'angular-nativo'],
'byline_member_id' => $ada->id,
]);
$page->tags()->attach([$this->tag('angular', 'angular')->id, $this->tag('rendimiento', 'performance')->id]);
$this->getJson('/api/public/v1/pages/angular-nativo?lang=es')
->assertOk()
->assertJsonPath('data.tags.1.slug', 'rendimiento')
->assertJsonPath('data.byline.name', 'Ada')
->assertJsonPath('data.byline.path', '/equipo/ada');
}
#[Test]
public function a_private_member_does_not_sign_in_public(): void
{
$page = Page::factory()->published()->create([
'slug' => ['es' => 'secreto'],
'byline_member_id' => Member::factory()->create(['is_public' => false])->id,
]);
$this->getJson('/api/public/v1/pages/secreto?lang=es')->assertJsonPath('data.byline', null);
}
#[Test]
public function the_blog_is_read_by_tag(): void
{
$angular = $this->tag('angular', 'angular');
Page::factory()->published()->create()->tags()->attach($angular);
Page::factory()->published()->create()->tags()->attach($angular);
Page::factory()->draft()->create()->tags()->attach($angular);
Page::factory()->published()->create();
$this->getJson('/api/public/v1/pages?lang=es&tag=angular')->assertJsonCount(2, 'data');
$this->getJson('/api/public/v1/tags?lang=es')
->assertOk()
->assertJsonPath('data.0.slug', 'angular')
->assertJsonPath('data.0.count', 2);
}
#[Test]
public function the_admin_api_writes_tags_and_bylines(): void
{
$this->withHeaders($this->apiHeadersFor($this->userWithRoles('editor')));
$tag = $this->postJson('/api/v1/tags', ['name' => ['es' => 'Accesibilidad'], 'slug' => ['es' => 'accesibilidad']])
->assertCreated()
->json('data.id');
$member = Member::factory()->create();
$page = Page::factory()->create();
$this->patchJson("/api/v1/pages/{$page->id}", ['tag_ids' => [$tag], 'byline_member_id' => $member->id])
->assertOk()
->assertJsonPath('data.tag_ids', [$tag])
->assertJsonPath('data.byline_member_id', $member->id);
// Untouched when not sent.
$this->patchJson("/api/v1/pages/{$page->id}", ['is_featured' => true])->assertJsonPath('data.tag_ids', [$tag]);
}
#[Test]
public function a_tag_is_made_from_the_page_form(): void
{
$this->actingAs($this->userWithRoles('editor'));
$page = Page::factory()->draft()->create();
Livewire::test(EditPage::class, ['record' => $page->getRouteKey()])
->assertFormFieldExists('tags')
->assertFormFieldExists('byline_member_id');
}
}

View File

@@ -10,6 +10,7 @@ use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Tag;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
@@ -59,10 +60,12 @@ class PublicApiContractTest extends TestCase
'slug' => ['es' => 'mi-proyecto'],
]);
Page::factory()->published()->create([
$page = Page::factory()->published()->create([
'title' => ['es' => 'Quiénes somos'],
'slug' => ['es' => 'quienes-somos'],
'byline_member_id' => Member::factory()->create(['is_public' => true])->id,
]);
$page->tags()->attach(Tag::factory()->create());
Member::factory()->create([
'name' => 'Ada Lovelace',