From a2f46b9f7d27f5972fbb60aca2f409bb86d4692d Mon Sep 17 00:00:00 2001 From: Guajiro <276488307+Guajir0-code@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:10:55 -0300 Subject: [PATCH 1/2] test: add WordPress fixtures and route coverage The app points Eloquent at an existing WordPress database, so no migration creates wp_posts, wp_users, wp_terms and friends. Any test that touched the database therefore could not run, and the single feature test in the repo (GET / asserting 200) failed on a missing database. Add a schema builder for the wp_* tables the app reads, small row builders, and smoke coverage for all seven routes in routes/web.php, including the highlighted-post exclusion, search, and the published/scheduled filtering the global scope is responsible for. withoutVite() keeps the suite independent of `npm run build`. ExampleTest is dropped: RoutesTest covers GET / properly. Co-Authored-By: Claude Opus 5 --- phpunit.xml | 3 +- tests/Feature/ExampleTest.php | 7 -- tests/Feature/RoutesTest.php | 139 ++++++++++++++++++++++++++++++ tests/Pest.php | 11 +++ tests/Support/WordPressSchema.php | 104 ++++++++++++++++++++++ tests/Support/Wp.php | 130 ++++++++++++++++++++++++++++ 6 files changed, 386 insertions(+), 8 deletions(-) delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/RoutesTest.php create mode 100644 tests/Support/WordPressSchema.php create mode 100644 tests/Support/Wp.php 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/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, + ]); + } +} From 6935442f5fd007191398ce7ca907d071327a1f8c Mon Sep 17 00:00:00 2001 From: Guajiro <276488307+Guajir0-code@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:37:05 -0300 Subject: [PATCH 2/2] chore: housekeeping and documentation Small fixes collected from a read through the codebase, none of which warrant a pull request of their own. Behaviour: * register HandleAppearance. It existed but was never added to the middleware stack, so the cookie the theme switcher writes was never read back and anyone who picked light or dark explicitly saw a flash of the other theme on every full page load * Inertia SSR is now env driven and off by default. Hardcoding it to true means every request pays for a failed connection to 127.0.0.1:13714 whenever the SSR process is not running, which is the case under `composer dev` * WpCategoryService filters by taxonomy. Slugs are only unique within a taxonomy, so a tag could be returned where a category was asked for * carousel pagination dots follow the number of slides instead of a hardcoded 5 * the search input no longer fires a request on blur * useAppearance keeps its ref at module scope, so the desktop and mobile theme buttons cannot disagree Cleanup: * Sidebar renders nothing instead of a 500px grey placeholder block * drop the Inspiring quote from the shared Inertia props, and its type: it was computed on every request and never rendered * group the OR in the search scope explicitly Docs: * README covering setup, the WordPress coupling, the conventions that live in content rather than code, and the testing story * document the Advanced Custom Fields dependency. The post subtitle comes from a meta key that ACF owns; if the field is reconfigured the subtitle silently disappears with no error anywhere Co-Authored-By: Claude Opus 5 --- README.md | 119 ++++++++++++++++++ app/Http/Middleware/HandleInertiaRequests.php | 4 - app/Models/WpPost.php | 10 +- app/Services/WpCategoryService.php | 6 +- bootstrap/app.php | 6 + config/inertia.php | 7 +- resources/js/components/Carousel.vue | 18 +-- resources/js/components/HeaderSearchForm.vue | 12 -- resources/js/components/Sidebar.vue | 11 +- resources/js/composables/useAppearance.ts | 6 +- resources/js/types/index.d.ts | 19 ++- 11 files changed, 176 insertions(+), 42 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0a7840 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# SourceVortex + +Front-end do [sourcevortex.com.br](https://sourcevortex.com.br) em Laravel + Inertia + Vue. + +Este projeto **não tem banco próprio**. Ele lê, em modo somente leitura, as tabelas de uma +instalação existente do WordPress (`wp_posts`, `wp_postmeta`, `wp_terms`, `wp_term_taxonomy`, +`wp_term_relationships`, `wp_users`, `wp_usermeta`) através de models Eloquent. O WordPress +continua sendo onde o conteúdo é escrito e administrado; aqui só se lê e se renderiza. + +## Stack + +| | | +|---|---| +| Back-end | Laravel 12, PHP 8.2+ | +| Front-end | Vue 3, TypeScript, Inertia 2 | +| Build | Vite 7, Tailwind 4 | +| Componentes | reka-ui (shadcn-vue) | +| Testes | Pest 3 | + +## Requisitos + +- PHP 8.2 ou superior +- Composer +- Node 22 ou superior +- MySQL **com as tabelas do WordPress já populadas** + +## Instalação + +```bash +composer install +npm ci +cp .env.example .env +php artisan key:generate +``` + +Aponte o `.env` para o banco do WordPress: + +```dotenv +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=nome_do_banco_wordpress +DB_USERNAME=usuario +DB_PASSWORD=senha + +# URL da instalação do WordPress, usada para montar os links do painel +WP_ADMIN_URL=https://exemplo.com.br +``` + +> **Não rode `php artisan migrate` contra o banco do WordPress** sem entender o efeito. +> As migrations do esqueleto do Laravel criam tabelas (`users`, `cache`, `jobs`) dentro +> do mesmo schema. Se `CACHE_STORE` ou `SESSION_DRIVER` estiverem em `database`, o Laravel +> passa a escrever no banco do WordPress. Prefira `redis` ou `file`. + +Para desenvolver: + +```bash +composer dev +``` + +Sobe servidor, fila, logs e Vite em paralelo. + +## Testes + +```bash +php artisan test +``` + +Os testes **não precisam** do banco do WordPress. `tests/Support/WordPressSchema.php` +cria as tabelas `wp_*` em sqlite na memória, e `tests/Support/Wp.php` traz construtores +para as linhas. Como o app não tem migration para essas tabelas, esse é o único lugar +onde o schema esperado está descrito — vale mantê-lo em dia com a instalação real. + +## Qualidade + +```bash +vendor/bin/pint --test # estilo PHP +npm run format:check # formatação do front +npm run lint:check # eslint +npm run type-check # vue-tsc +``` + +Os quatro rodam no CI e reprovam o build. + +## Convenções que vêm do WordPress + +Algumas regras de negócio moram no conteúdo, não no código: + +| Convenção | Efeito | +|---|---| +| Categoria `destaques` | Posts nela aparecem no carrossel da home e **saem** da listagem principal | +| Menu chamado `Menu` | É o menu principal do site | +| Página com slug `politica-de-privacidade` | Alimenta a rota `/politica-de-privacidade` | + +As URLs seguem o permalink padrão do WordPress (`/%year%/%monthnum%/%postname%`), o que +mantém os endereços do site antigo válidos. + +## ⚠️ Dependência de plugin: Advanced Custom Fields + +O subtítulo exibido na capa do post vem de `wp_postmeta` com `meta_key = 'subtitle'`. +Esse campo é criado pelo **Advanced Custom Fields** no WordPress — o tema V1 o lia com +`get_field("subtitle")`. + +Isso significa que: + +- o ACF precisa continuar instalado e o campo `subtitle` precisa continuar existindo; +- se o campo for movido para dentro de um *group* ou *repeater*, o `meta_key` muda de + formato e o subtítulo **some silenciosamente** aqui, sem erro nenhum. + +Não há como este projeto detectar essa quebra sozinho. Se o subtítulo sumir do site, +comece a investigação pela configuração do ACF. + +## Diferenças em relação ao tema antigo + +O tema WordPress que este projeto substitui tinha alguns recursos que não foram portados: + +- **Comentários** — descontinuados por decisão de produto. +- **Anúncios (AdSense)** — o tema tinha quatro posições. Não estão aqui. +- **AMP, post formats e página de biografia** — não portados. diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0dd251b..ff0b8ea 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,7 +4,6 @@ use App\Services\WpAuthService; use App\Services\WpMenuService; -use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; use Inertia\Middleware; use Tighten\Ziggy\Ziggy; @@ -39,14 +38,11 @@ public function version(Request $request): ?string */ public function share(Request $request): array { - [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); - $wpUser = WpAuthService::getLoggedInWpUser(); return [ ...parent::share($request), 'name' => config('app.name'), - 'quote' => ['message' => trim($message), 'author' => trim($author)], 'auth' => [ 'user' => $request->user(), ], diff --git a/app/Models/WpPost.php b/app/Models/WpPost.php index 0ae7b32..30c6706 100644 --- a/app/Models/WpPost.php +++ b/app/Models/WpPost.php @@ -90,8 +90,14 @@ public function useSearchTerm(Builder $query, ?string $searchTerm = null): void return; } - $query->where('post_title', 'LIKE', "%{$searchTerm}%") - ->orWhere('post_content', 'LIKE', "%{$searchTerm}%"); + // Grouped explicitly. Laravel currently nests the wheres a named scope + // adds, so the OR does not leak today — but that is a property of how + // the scope is invoked, not of this code, and it breaks the moment the + // condition is inlined into a query. + $query->where(function (Builder $query) use ($searchTerm) { + $query->where('post_title', 'LIKE', "%{$searchTerm}%") + ->orWhere('post_content', 'LIKE', "%{$searchTerm}%"); + }); } #[Scope] diff --git a/app/Services/WpCategoryService.php b/app/Services/WpCategoryService.php index 53e2f6b..1fc9dea 100644 --- a/app/Services/WpCategoryService.php +++ b/app/Services/WpCategoryService.php @@ -13,7 +13,11 @@ public static function getCategoryBySlug($slug) return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) { return WpTerm::query() - ->where('slug', $slug) + // Slugs are only unique within a taxonomy: without this filter a + // tag sharing a category's slug could be returned instead. + ->join('wp_term_taxonomy', 'wp_term_taxonomy.term_id', '=', 'wp_terms.term_id') + ->where('wp_term_taxonomy.taxonomy', 'category') + ->where('wp_terms.slug', $slug) ->first(); }); } diff --git a/bootstrap/app.php b/bootstrap/app.php index a827a7f..14455db 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ + // Reads the appearance cookie the theme switcher writes and shares + // it with the root view. It existed but was never registered, so + // the cookie was written and never read, and anyone who picked a + // theme explicitly got a flash of the wrong one on every full load. + HandleAppearance::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/config/inertia.php b/config/inertia.php index d15cad9..575d918 100644 --- a/config/inertia.php +++ b/config/inertia.php @@ -15,9 +15,12 @@ | */ + // Off by default: when this is true and no SSR process is listening, every + // request pays for a failed connection to 127.0.0.1:13714 before falling + // back to client rendering. `composer dev` does not start that process. 'ssr' => [ - 'enabled' => true, - 'url' => 'http://127.0.0.1:13714', + 'enabled' => env('INERTIA_SSR_ENABLED', false), + 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), ], diff --git a/resources/js/components/Carousel.vue b/resources/js/components/Carousel.vue index 5386bec..15c76b4 100644 --- a/resources/js/components/Carousel.vue +++ b/resources/js/components/Carousel.vue @@ -45,13 +45,15 @@ watchOnce(api, (api) => { :opts="{ loop: true, }" - :plugins="[Autoplay({ - delay: 5000, - active: true, - stopOnFocusIn: false, - stopOnInteraction: false, - stopOnMouseEnter: false, - })]" + :plugins="[ + Autoplay({ + delay: 5000, + active: true, + stopOnFocusIn: false, + stopOnInteraction: false, + stopOnMouseEnter: false, + }), + ]" > @@ -62,7 +64,7 @@ watchOnce(api, (api) => {