diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php
index 022665d..985fa69 100644
--- a/app/Http/Controllers/PostController.php
+++ b/app/Http/Controllers/PostController.php
@@ -93,7 +93,7 @@ public function show($year, $month, $slug)
$relatedPosts = WpPostService::getRelatedPosts($post);
return inertia('PostContent', [
- 'post' => WpPostResource::make($post),
+ 'post' => WpPostResource::make($post)->detailed(),
'relatedPosts' => WpPostResource::collection($relatedPosts),
]);
}
diff --git a/app/Http/Resources/WpPostResource.php b/app/Http/Resources/WpPostResource.php
index 88891d4..22dd4e0 100644
--- a/app/Http/Resources/WpPostResource.php
+++ b/app/Http/Resources/WpPostResource.php
@@ -26,6 +26,28 @@
*/
class WpPostResource extends JsonResource
{
+ /**
+ * Whether to include the heavy, single-post-only fields.
+ */
+ private bool $detailed = false;
+
+ /**
+ * Opt in to the full payload: processed content, subtitle, tags and author
+ * biography.
+ *
+ * This used to be inferred from $request->routeIs('post'), which meant the
+ * related posts rendered on that same page were also given the full
+ * treatment — including running EmbedProcessorService over their content —
+ * even though the component that renders them only reads the title,
+ * thumbnail, slug and date.
+ */
+ public function detailed(): static
+ {
+ $this->detailed = true;
+
+ return $this;
+ }
+
/**
* Transform the resource into an array.
*
@@ -50,7 +72,7 @@ public function toArray(Request $request): array
],
];
- if ($request->routeIs('post')) {
+ if ($this->detailed) {
$postData['content'] = EmbedProcessorService::processContent($this->post_content);
$postData['subtitle'] = $this->metadata->filter(fn ($meta) => $meta->meta_key === 'subtitle')?->first()?->meta_value;
$postData['date']['day'] = $parsedPostDate->format('d');
diff --git a/app/Models/WpAttachment.php b/app/Models/WpAttachment.php
new file mode 100644
index 0000000..45918e5
--- /dev/null
+++ b/app/Models/WpAttachment.php
@@ -0,0 +1,21 @@
+ wp_postmeta
+ * (_thumbnail_id) -> wp_posts (the attachment). Reading it through
+ * $this->metadata() instead would issue two queries for every post.
+ */
+ public function thumbnail(): HasOneThrough
{
- if (! $postMeta = $this->metadata()->thumbnailId()->first()) {
- return '';
- }
+ return $this->hasOneThrough(
+ WpAttachment::class,
+ WpPostMeta::class,
+ 'post_id', // wp_postmeta.post_id -> wp_posts.ID (this model)
+ 'ID', // wp_posts.ID (attachment) -> wp_postmeta.meta_value
+ 'ID',
+ 'meta_value',
+ )
+ ->where('wp_postmeta.meta_key', '_thumbnail_id')
+ ->where('wp_posts.post_type', 'attachment');
+ }
- return DB::table('wp_posts')
- ->where('ID', $postMeta->meta_value)
- ->where('post_type', 'attachment')
- ->value('guid') ?? '';
+ public function getThumbnail(): string
+ {
+ return $this->thumbnail?->guid ?? '';
}
#[Scope]
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 452e6b6..fcf4c23 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,7 @@
namespace App\Providers;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,8 @@ public function register(): void
*/
public function boot(): void
{
- //
+ // Turns an accidental lazy load into a loud failure while developing
+ // and in CI, instead of a silent extra query per record in production.
+ Model::preventLazyLoading(! $this->app->isProduction());
}
}
diff --git a/app/Services/WpPostService.php b/app/Services/WpPostService.php
index 6e463f1..bedc82b 100644
--- a/app/Services/WpPostService.php
+++ b/app/Services/WpPostService.php
@@ -19,7 +19,7 @@ public static function getHighlightedPosts(): Collection
return cache()->remember($cacheKey, $cacheTTL, function () {
return WpPost::query()
- ->with(['metadata'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug('destaques')
->limit(self::DEFAULT_LIMIT_HIGHLIGHT_POSTS)
->get();
@@ -34,7 +34,7 @@ public static function getHomePosts(int $page, ?string $searchTerm = null): Leng
return cache()->remember($cacheKey, $cacheTTL, function () use ($searchTerm) {
return WpPost::query()
- ->with(['metadata'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereNotCategorySlug('destaques')
->useSearchTerm($searchTerm)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
@@ -48,7 +48,7 @@ public static function getPostsByCategorySlug(int $page, string $slug): LengthAw
return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) {
return WpPost::query()
- ->with(['metadata', 'author'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug($slug)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
});
@@ -61,7 +61,7 @@ public static function getPostsByAuthorNicename(int $page, string $nicename): Le
return cache()->remember($cacheKey, $cacheTTL, function () use ($nicename) {
return WpPost::query()
- ->with(['metadata', 'author'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereAuthorNicename($nicename)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
});
@@ -74,7 +74,7 @@ public static function getPostsByYearMonth(int $page, string $year, string $mont
return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month) {
return WpPost::query()
- ->with(['metadata', 'author'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereYear('post_date', $year)
->whereMonth('post_date', $month)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
@@ -88,7 +88,7 @@ public static function getPostsByTagSlug(int $page, string $slug): LengthAwarePa
return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) {
return WpPost::query()
- ->with(['metadata', 'author'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereTagSlug($slug)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
});
@@ -101,6 +101,9 @@ public static function getPostByYearMonthSlug($year, $month, $slug): WpPost
return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month, $slug) {
return WpPost::query()
+ // The single post view also renders the subtitle meta and the
+ // author biography, so it needs more than the listings do.
+ ->with(['terms', 'thumbnail', 'metadata', 'author.metadata'])
->whereYear('post_date', $year)
->whereMonth('post_date', $month)
->where('post_name', $slug)
@@ -124,7 +127,7 @@ public static function getRelatedPosts(WpPost $post, int $limit = 3): Collection
}
return WpPost::query()
- ->with(['metadata'])
+ ->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug($categorySlug)
->where('ID', '!=', $post->ID)
->limit($limit)
diff --git a/phpunit.xml b/phpunit.xml
index c09b5bc..61c031c 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,7 +22,8 @@
-
+
+
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index 1e2a000..0000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,7 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
-});
diff --git a/tests/Feature/QueryCountTest.php b/tests/Feature/QueryCountTest.php
new file mode 100644
index 0000000..acaa2a4
--- /dev/null
+++ b/tests/Feature/QueryCountTest.php
@@ -0,0 +1,80 @@
+ 'autor', 'user_nicename' => 'autor']);
+
+ for ($i = 1; $i <= $count; $i++) {
+ $post = Wp::post([
+ 'post_author' => $author->ID,
+ 'post_title' => "Post {$i}",
+ 'post_name' => "post-{$i}",
+ 'post_date' => '2026-07-'.str_pad((string) $i, 2, '0', STR_PAD_LEFT).' 12:00:00',
+ ]);
+
+ Wp::categorise($post->ID, 'games');
+ Wp::thumbnail($post->ID);
+ }
+}
+
+it('does not scale queries with the number of posts on the home page', function () {
+ seedPosts(3);
+ $few = countQueries(fn () => $this->get('/')->assertOk());
+
+ // Fresh state, ten times the posts.
+ Tests\Support\WordPressSchema::create();
+ cache()->flush();
+ seedPosts(30);
+ $many = countQueries(fn () => $this->get('/')->assertOk());
+
+ // The point of the test: the count must not track the post count.
+ expect($many)->toBe($few);
+});
+
+it('keeps the home page within a query budget', function () {
+ seedPosts(10);
+
+ // measured: 7
+ expect(countQueries(fn () => $this->get('/')->assertOk()))
+ ->toBeLessThanOrEqual(9);
+});
+
+it('keeps the single post page within a query budget', function () {
+ seedPosts(10);
+
+ // measured: 11 — higher than the listings because it also loads the
+ // subtitle meta, the author biography and the related posts.
+ expect(countQueries(fn () => $this->get('/2026/07/post-1')->assertOk()))
+ ->toBeLessThanOrEqual(13);
+});
+
+it('keeps the category listing within a query budget', function () {
+ seedPosts(10);
+
+ // measured: 6
+ expect(countQueries(fn () => $this->get('/category/games')->assertOk()))
+ ->toBeLessThanOrEqual(8);
+});
diff --git a/tests/Feature/RoutesTest.php b/tests/Feature/RoutesTest.php
new file mode 100644
index 0000000..8ebf750
--- /dev/null
+++ b/tests/Feature/RoutesTest.php
@@ -0,0 +1,139 @@
+author = Wp::user([
+ 'user_login' => 'mayron',
+ 'user_nicename' => 'mayron',
+ 'display_name' => 'Autor Um',
+ 'user_email' => 'autor@example.com',
+ ]);
+
+ $this->post = Wp::post([
+ 'post_author' => $this->author->ID,
+ 'post_title' => 'Primeiro post',
+ 'post_name' => 'primeiro-post',
+ 'post_date' => '2026-07-15 12:00:00',
+ ]);
+
+ Wp::categorise($this->post->ID, 'games');
+ Wp::categorise($this->post->ID, 'lancamentos', 'post_tag');
+ Wp::thumbnail($this->post->ID);
+});
+
+it('renders the home page', function () {
+ $this->get('/')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Home')
+ ->has('posts.data', 1)
+ ->where('posts.data.0.title', 'Primeiro post')
+ );
+});
+
+it('excludes highlighted posts from the home listing', function () {
+ $highlighted = Wp::post(['post_title' => 'Em destaque', 'post_name' => 'em-destaque']);
+ Wp::categorise($highlighted->ID, 'destaques');
+
+ $this->get('/')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('posts.data', 1)
+ ->has('highlightedPosts.data', 1)
+ ->where('posts.data.0.title', 'Primeiro post')
+ ->where('highlightedPosts.data.0.title', 'Em destaque')
+ );
+});
+
+it('renders a single post', function () {
+ $this->get('/2026/07/primeiro-post')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('PostContent')
+ ->where('post.data.title', 'Primeiro post')
+ ->where('post.data.slug', 'primeiro-post')
+ );
+});
+
+it('returns 404 for an unknown post slug', function () {
+ $this->get('/2026/07/nao-existe')->assertNotFound();
+});
+
+it('renders the category listing', function () {
+ $this->get('/category/games')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('CategoryPosts')
+ ->has('posts.data', 1)
+ );
+});
+
+it('renders the tag listing', function () {
+ $this->get('/tag/lancamentos')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('TagPosts')
+ ->has('posts.data', 1)
+ );
+});
+
+it('renders the author listing', function () {
+ $this->get('/author/mayron')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('AuthorPosts')
+ ->has('posts.data', 1)
+ );
+});
+
+it('renders the year and month archive', function () {
+ $this->get('/2026/07')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('YearMonthPosts')
+ ->has('posts.data', 1)
+ );
+});
+
+it('renders the privacy policy page', function () {
+ Wp::post([
+ 'post_title' => 'Politica de Privacidade',
+ 'post_name' => 'politica-de-privacidade',
+ 'post_type' => 'page',
+ ]);
+
+ $this->get('/politica-de-privacidade')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('PrivacyPolicy')
+ ->where('page.data.title', 'Politica de Privacidade')
+ );
+});
+
+it('finds posts by search term', function () {
+ Wp::post(['post_title' => 'Outro assunto', 'post_name' => 'outro-assunto']);
+
+ $this->get('/?s=Primeiro')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('searchTerm', 'Primeiro')
+ ->has('posts.data', 1)
+ ->where('posts.data.0.title', 'Primeiro post')
+ );
+});
+
+it('hides posts that are not published', function () {
+ Wp::post(['post_title' => 'Rascunho', 'post_name' => 'rascunho', 'post_status' => 'draft']);
+ Wp::post(['post_title' => 'Futuro', 'post_name' => 'futuro', 'post_date' => '2099-01-01 00:00:00']);
+
+ $this->get('/')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->has('posts.data', 1));
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 40d096b..3c76ee0 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -13,6 +13,17 @@
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
+ ->beforeEach(function () {
+ // The app reads an existing WordPress database, so there are no
+ // migrations for wp_* tables. Build them per test instead.
+ Tests\Support\WordPressSchema::create();
+
+ // Rendering the root Blade view resolves @vite, which needs a built
+ // manifest. Stub it so the suite does not depend on `npm run build`.
+ $this->withoutVite();
+
+ cache()->flush();
+ })
->in('Feature');
/*
diff --git a/tests/Support/WordPressSchema.php b/tests/Support/WordPressSchema.php
new file mode 100644
index 0000000..df23af6
--- /dev/null
+++ b/tests/Support/WordPressSchema.php
@@ -0,0 +1,104 @@
+ Drop order matters only for readability; sqlite has no FKs here.
+ */
+ private const TABLES = [
+ 'wp_posts',
+ 'wp_postmeta',
+ 'wp_users',
+ 'wp_usermeta',
+ 'wp_terms',
+ 'wp_term_taxonomy',
+ 'wp_term_relationships',
+ ];
+
+ public static function create(): void
+ {
+ self::drop();
+
+ Schema::create('wp_posts', function (Blueprint $table) {
+ $table->id('ID');
+ $table->unsignedBigInteger('post_author')->default(0);
+ $table->dateTime('post_date')->nullable();
+ $table->longText('post_content')->nullable();
+ $table->text('post_title')->nullable();
+ $table->text('post_excerpt')->nullable();
+ $table->string('post_status', 20)->default('publish');
+ $table->string('post_name', 200)->default('');
+ $table->string('post_type', 20)->default('post');
+ $table->string('guid', 255)->default('');
+ $table->integer('menu_order')->default(0);
+ $table->unsignedBigInteger('post_parent')->default(0);
+ });
+
+ Schema::create('wp_postmeta', function (Blueprint $table) {
+ $table->id('meta_id');
+ $table->unsignedBigInteger('post_id')->default(0);
+ $table->string('meta_key', 255)->nullable();
+ $table->longText('meta_value')->nullable();
+ });
+
+ Schema::create('wp_users', function (Blueprint $table) {
+ $table->id('ID');
+ $table->string('user_login', 60)->default('');
+ $table->string('user_pass', 255)->default('');
+ $table->string('user_nicename', 50)->default('');
+ $table->string('user_email', 100)->default('');
+ $table->string('display_name', 250)->default('');
+ });
+
+ Schema::create('wp_usermeta', function (Blueprint $table) {
+ $table->id('umeta_id');
+ $table->unsignedBigInteger('user_id')->default(0);
+ $table->string('meta_key', 255)->nullable();
+ $table->longText('meta_value')->nullable();
+ });
+
+ Schema::create('wp_terms', function (Blueprint $table) {
+ $table->id('term_id');
+ $table->string('name', 200)->default('');
+ $table->string('slug', 200)->default('');
+ });
+
+ Schema::create('wp_term_taxonomy', function (Blueprint $table) {
+ $table->id('term_taxonomy_id');
+ $table->unsignedBigInteger('term_id')->default(0);
+ $table->string('taxonomy', 32)->default('');
+ $table->longText('description')->nullable();
+ $table->unsignedBigInteger('parent')->default(0);
+ $table->bigInteger('count')->default(0);
+ });
+
+ Schema::create('wp_term_relationships', function (Blueprint $table) {
+ $table->unsignedBigInteger('object_id')->default(0);
+ $table->unsignedBigInteger('term_taxonomy_id')->default(0);
+ $table->integer('term_order')->default(0);
+ });
+ }
+
+ public static function drop(): void
+ {
+ foreach (self::TABLES as $table) {
+ Schema::dropIfExists($table);
+ }
+ }
+}
diff --git a/tests/Support/Wp.php b/tests/Support/Wp.php
new file mode 100644
index 0000000..9c29ebf
--- /dev/null
+++ b/tests/Support/Wp.php
@@ -0,0 +1,130 @@
+insertGetId(array_merge([
+ 'user_login' => 'autor',
+ 'user_pass' => '$P$Bexamplehashexamplehashexample',
+ 'user_nicename' => 'autor',
+ 'user_email' => 'autor@example.com',
+ 'display_name' => 'Autor de Teste',
+ ], $attributes), 'ID');
+
+ return WpUser::query()->findOrFail($id);
+ }
+
+ public static function post(array $attributes = []): WpPost
+ {
+ $id = DB::table('wp_posts')->insertGetId(array_merge([
+ 'post_author' => 1,
+ 'post_date' => '2026-07-15 12:00:00',
+ 'post_title' => 'Post de teste',
+ 'post_excerpt' => 'Resumo do post.',
+ 'post_content' => 'Conteudo do post.',
+ 'post_name' => 'post-de-teste',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ], $attributes), 'ID');
+
+ return WpPost::withoutGlobalScopes()->findOrFail($id);
+ }
+
+ /**
+ * Creates a term in the given taxonomy and returns its term_taxonomy_id.
+ */
+ public static function term(string $slug, string $taxonomy = 'category', ?string $name = null): int
+ {
+ $termId = DB::table('wp_terms')->insertGetId([
+ 'name' => $name ?? ucfirst($slug),
+ 'slug' => $slug,
+ ], 'term_id');
+
+ return DB::table('wp_term_taxonomy')->insertGetId([
+ 'term_id' => $termId,
+ 'taxonomy' => $taxonomy,
+ ], 'term_taxonomy_id');
+ }
+
+ public static function attach(int $postId, int $termTaxonomyId): void
+ {
+ DB::table('wp_term_relationships')->insert([
+ 'object_id' => $postId,
+ 'term_taxonomy_id' => $termTaxonomyId,
+ ]);
+ }
+
+ /**
+ * Convenience: create the term if needed and attach it in one call.
+ */
+ public static function categorise(int $postId, string $slug, string $taxonomy = 'category'): int
+ {
+ $ttId = DB::table('wp_term_taxonomy')
+ ->join('wp_terms', 'wp_terms.term_id', '=', 'wp_term_taxonomy.term_id')
+ ->where('wp_terms.slug', $slug)
+ ->where('wp_term_taxonomy.taxonomy', $taxonomy)
+ ->value('wp_term_taxonomy.term_taxonomy_id');
+
+ $ttId ??= self::term($slug, $taxonomy);
+
+ self::attach($postId, $ttId);
+
+ return $ttId;
+ }
+
+ /**
+ * Attaches a featured image: an attachment post plus the _thumbnail_id meta
+ * that WpPost::getThumbnail() resolves.
+ */
+ public static function thumbnail(int $postId, string $url = 'https://example.com/capa.jpg'): int
+ {
+ $attachmentId = DB::table('wp_posts')->insertGetId([
+ 'post_title' => 'capa',
+ 'post_name' => 'capa',
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_date' => '2026-07-15 12:00:00',
+ 'guid' => $url,
+ ], 'ID');
+
+ DB::table('wp_postmeta')->insert([
+ 'post_id' => $postId,
+ 'meta_key' => '_thumbnail_id',
+ 'meta_value' => (string) $attachmentId,
+ ]);
+
+ return $attachmentId;
+ }
+
+ public static function postMeta(int $postId, string $key, string $value): void
+ {
+ DB::table('wp_postmeta')->insert([
+ 'post_id' => $postId,
+ 'meta_key' => $key,
+ 'meta_value' => $value,
+ ]);
+ }
+
+ public static function userMeta(int $userId, string $key, string $value): void
+ {
+ DB::table('wp_usermeta')->insert([
+ 'user_id' => $userId,
+ 'meta_key' => $key,
+ 'meta_value' => $value,
+ ]);
+ }
+}