Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/Http/Controllers/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ class PostController extends Controller
public function homePosts(Request $request)
{
$page = $request->integer('page', 1);
$searchTerm = $request->get('s');

// Normalised here rather than inside the service so the value used for
// the query is the same one echoed back to the page.
$searchTerm = WpPostService::normaliseSearchTerm($request->get('s'));

$posts = WpPostService::getHomePosts($page, $searchTerm);
$highlightedPosts = WpPostService::getHighlightedPosts();

Expand Down
11 changes: 10 additions & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
Expand All @@ -19,6 +22,12 @@ public function register(): void
*/
public function boot(): void
{
//
// Only requests carrying ?s= are limited. Ordinary home page traffic,
// including pagination, is left alone.
RateLimiter::for('search', function (Request $request) {
return $request->filled('s')
? Limit::perMinute(20)->by($request->ip())
: Limit::none();
});
}
}
35 changes: 34 additions & 1 deletion app/Services/WpPostService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ class WpPostService

public const DEFAULT_PER_PAGE_ON_LISTS = 10;

/**
* Shorter than this and a LIKE '%..%' scan matches most of the table while
* returning nothing useful.
*/
public const MIN_SEARCH_LENGTH = 3;

public const MAX_SEARCH_LENGTH = 60;

/**
* Collapses a raw ?s= value into the form used both for querying and for
* the cache key, or null when it is not worth searching for.
*
* Without this, "Games", "games" and " games " are three cache entries for
* one result set, and every distinct string a crawler invents becomes a
* day-long entry of its own.
*/
public static function normaliseSearchTerm(?string $term): ?string
{
$term = trim(preg_replace('/\s+/u', ' ', (string) $term));

if (mb_strlen($term) < self::MIN_SEARCH_LENGTH) {
return null;
}

return mb_strtolower(mb_substr($term, 0, self::MAX_SEARCH_LENGTH));
}

public static function getHighlightedPosts(): Collection
{
$cacheKey = 'highlighted_posts';
Expand All @@ -28,9 +55,15 @@ public static function getHighlightedPosts(): Collection

public static function getHomePosts(int $page, ?string $searchTerm = null): LengthAwarePaginator
{
$searchTerm = self::normaliseSearchTerm($searchTerm);

$searchTermHash = $searchTerm ? md5($searchTerm) : null;
$cacheKey = $searchTerm ? "search_{$searchTermHash}_posts_page_{$page}" : "home_posts_page_{$page}";
$cacheTTL = now()->addDay();

// Search results are keyed by arbitrary visitor input, so they are kept
// for minutes rather than a day: the entries are unbounded in number
// and each one is only ever useful to whoever typed that exact term.
$cacheTTL = $searchTerm ? now()->addMinutes(5) : now()->addDay();

return cache()->remember($cacheKey, $cacheTTL, function () use ($searchTerm) {
return WpPost::query()
Expand Down
3 changes: 2 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
Expand Down
4 changes: 3 additions & 1 deletion routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', [PostController::class, 'homePosts'])->name('home.posts');
Route::get('/', [PostController::class, 'homePosts'])
->middleware('throttle:search')
->name('home.posts');
Route::get('/politica-de-privacidade', [PostController::class, 'privacyPolicy'])->name('page.privacy-policy');
Route::get('/author/{author}', [PostController::class, 'authorPosts'])->name('author.posts');
Route::get('/category/{category}', [PostController::class, 'categoryPosts'])->name('category.posts');
Expand Down
7 changes: 0 additions & 7 deletions tests/Feature/ExampleTest.php

This file was deleted.

140 changes: 140 additions & 0 deletions tests/Feature/RoutesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

use Tests\Support\Wp;

/**
* Smoke coverage for every route in routes/web.php.
*
* Before this file the only feature test was `GET /` asserting 200, and it
* could not pass because no wp_* table existed in the test database.
*/
beforeEach(function () {
$this->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
// normalised to lower case before querying and before being echoed
->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));
});
81 changes: 81 additions & 0 deletions tests/Feature/SearchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

use App\Services\WpPostService;
use Tests\Support\Wp;

beforeEach(function () {
Wp::user(['user_login' => 'autor', 'user_nicename' => 'autor']);

foreach (['Guia de Linux', 'Analise de games', 'Noticia sobre hardware'] as $i => $title) {
$post = Wp::post([
'post_title' => $title,
'post_name' => 'post-'.($i + 1),
'post_date' => '2026-07-0'.($i + 1).' 12:00:00',
]);
Wp::categorise($post->ID, 'geral');
}
});

it('normalises whitespace and case', function (?string $input, ?string $expected) {
expect(WpPostService::normaliseSearchTerm($input))->toBe($expected);
})->with([
['Linux', 'linux'],
[' linux ', 'linux'],
["linux\tmint", 'linux mint'],
['LINUX MINT', 'linux mint'],
[null, null],
['', null],
[' ', null],
// below MIN_SEARCH_LENGTH
['ab', null],
[' a ', null],
]);

it('caps very long terms', function () {
$term = str_repeat('a', 200);

expect(mb_strlen(WpPostService::normaliseSearchTerm($term)))
->toBe(WpPostService::MAX_SEARCH_LENGTH);
});

it('finds posts by term', function () {
$this->get('/?s=linux')
->assertOk()
->assertInertia(fn ($page) => $page
->where('searchTerm', 'linux')
->has('posts.data', 1)
->where('posts.data.0.title', 'Guia de Linux')
);
});

it('is case insensitive', function () {
$this->get('/?s=LINUX')
->assertOk()
->assertInertia(fn ($page) => $page
->where('searchTerm', 'linux')
->has('posts.data', 1)
);
});

it('ignores terms that are too short, instead of scanning for them', function () {
// The page must behave as if no search was made: all posts, no term echoed.
$this->get('/?s=ab')
->assertOk()
->assertInertia(fn ($page) => $page
->where('searchTerm', null)
->has('posts.data', 3)
);
});

it('rate limits search requests but not ordinary traffic', function () {
// Plain home page requests are not limited.
for ($i = 0; $i < 25; $i++) {
$this->get('/')->assertOk();
}

for ($i = 0; $i < 20; $i++) {
$this->get('/?s=linux')->assertOk();
}

$this->get('/?s=linux')->assertStatus(429);
});
11 changes: 11 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

/*
Expand Down
Loading