diff --git a/app/Services/EmbedProcessorService.php b/app/Services/EmbedProcessorService.php index 650bb65..0b69ee8 100644 --- a/app/Services/EmbedProcessorService.php +++ b/app/Services/EmbedProcessorService.php @@ -2,14 +2,16 @@ namespace App\Services; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; - class EmbedProcessorService { /** - * Process HTML content and convert embed blocks to renderable format + * Process HTML content and convert embed blocks to renderable format. + * + * Every provider is handled with markup only. The provider scripts that + * PostContentText.vue loads (platform.twitter.com/widgets.js, + * instagram.com/embed.js, embed.reddit.com/widgets.js) turn the markup + * below into the real embed on the client — the same path the previous + * oEmbed fallback already relied on whenever a request failed. */ public static function processContent(string $content): string { @@ -36,18 +38,10 @@ private static function processTwitterEmbeds(string $content): string if (preg_match('/status\/(\d+)/', $url, $idMatch)) { $tweetId = $idMatch[1]; - // Try to get oEmbed data from Twitter API (cached) - $embedHtml = self::getTwitterOEmbed($url); - - if ($embedHtml) { - return '
' . $embedHtml . '
'; - } - - // Fallback: create a basic embed structure that Twitter widget.js can enhance - return '
' . - '
' . - 'Ver tweet' . - '
' . + return '
'. + ''. '
'; } @@ -55,33 +49,6 @@ private static function processTwitterEmbeds(string $content): string }, $content) ?? $content; } - /** - * Get Twitter oEmbed HTML - */ - private static function getTwitterOEmbed(string $url): ?string - { - $cacheKey = 'twitter_oembed_' . md5($url); - - return Cache::remember($cacheKey, 86400, function () use ($url) { - try { - $response = Http::timeout(5)->get('https://publish.twitter.com/oembed', [ - 'url' => $url, - 'omit_script' => 'true', - 'dnt' => 'true', - ]); - - if ($response->successful()) { - $data = $response->json(); - return $data['html'] ?? null; - } - } catch (\Exception $e) { - Log::warning('Failed to fetch Twitter oEmbed: ' . $e->getMessage()); - } - - return null; - }); - } - /** * Process YouTube embed blocks */ @@ -93,14 +60,14 @@ private static function processYouTubeEmbeds(string $content): string return preg_replace_callback($pattern, function ($matches) { $videoId = $matches[2]; - return '
' . - '' . + return '
'. + ''. '
'; }, $content) ?? $content; } @@ -117,48 +84,14 @@ private static function processInstagramEmbeds(string $content): string $url = html_entity_decode(trim($matches[1])); $postId = $matches[2]; - // Try to get oEmbed data from Instagram API (cached) - $embedHtml = self::getInstagramOEmbed($url); - - if ($embedHtml) { - return '
' . $embedHtml . '
'; - } - - // Fallback - return '
' . - '
' . - 'Ver no Instagram' . - '
' . + return '
'. + '
'. + 'Ver no Instagram'. + '
'. '
'; }, $content) ?? $content; } - /** - * Get Instagram oEmbed HTML - */ - private static function getInstagramOEmbed(string $url): ?string - { - $cacheKey = 'instagram_oembed_' . md5($url); - - return Cache::remember($cacheKey, 86400, function () use ($url) { - try { - $response = Http::timeout(5)->get('https://api.instagram.com/oembed', [ - 'url' => $url, - 'omitscript' => 'true', - ]); - - if ($response->successful()) { - $data = $response->json(); - return $data['html'] ?? null; - } - } catch (\Exception $e) { - Log::warning('Failed to fetch Instagram oEmbed: ' . $e->getMessage()); - } - - return null; - }); - } - /** * Process Reddit embed blocks */ @@ -170,44 +103,11 @@ private static function processRedditEmbeds(string $content): string return preg_replace_callback($pattern, function ($matches) { $url = html_entity_decode(trim($matches[1])); - // Try to get oEmbed data from Reddit API (cached) - $embedHtml = self::getRedditOEmbed($url); - - if ($embedHtml) { - return '
' . $embedHtml . '
'; - } - - // Fallback: link to the Reddit post - return '
' . - '
' . - 'Ver no Reddit' . - '
' . + return '
'. + '
'. + 'Ver no Reddit'. + '
'. '
'; }, $content) ?? $content; } - - /** - * Get Reddit oEmbed HTML - */ - private static function getRedditOEmbed(string $url): ?string - { - $cacheKey = 'reddit_oembed_' . md5($url); - - return Cache::remember($cacheKey, 86400, function () use ($url) { - try { - $response = Http::timeout(5)->get('https://www.reddit.com/oembed', [ - 'url' => $url, - ]); - - if ($response->successful()) { - $data = $response->json(); - return $data['html'] ?? null; - } - } catch (\Exception $e) { - Log::warning('Failed to fetch Reddit oEmbed: ' . $e->getMessage()); - } - - return null; - }); - } } 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/EmbedProcessorTest.php b/tests/Feature/EmbedProcessorTest.php new file mode 100644 index 0000000..9cf9631 --- /dev/null +++ b/tests/Feature/EmbedProcessorTest.php @@ -0,0 +1,103 @@ +' + .'
'.$url.'
' + .''; +} + +it('renders a twitter embed without calling the network', function () { + $html = EmbedProcessorService::processContent( + wpEmbedBlock('twitter', 'https://twitter.com/anthropicai/status/1234567890') + ); + + expect($html) + ->toContain('embed-twitter') + ->toContain('data-tweet-id="1234567890"') + // the class the twitter widget script looks for + ->toContain('class="twitter-tweet"'); +}); + +it('renders an instagram embed without calling the network', function () { + $html = EmbedProcessorService::processContent( + wpEmbedBlock('instagram', 'https://www.instagram.com/p/AbCdEf123/') + ); + + expect($html) + ->toContain('embed-instagram') + ->toContain('data-instagram-id="AbCdEf123"') + // the class instagram's embed.js looks for + ->toContain('class="instagram-media"') + ->toContain('data-instgrm-permalink'); +}); + +it('renders a reddit embed without calling the network', function () { + $html = EmbedProcessorService::processContent( + wpEmbedBlock('reddit', 'https://www.reddit.com/r/php/comments/abc/titulo/') + ); + + expect($html) + ->toContain('embed-reddit') + ->toContain('class="reddit-embed-bq"'); +}); + +it('renders a youtube embed as a privacy friendly iframe', function () { + $html = EmbedProcessorService::processContent( + wpEmbedBlock('youtube', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ') + ); + + expect($html) + ->toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ') + ->toContain('loading="lazy"'); +}); + +it('processes several embeds in one document without any request', function () { + $content = wpEmbedBlock('twitter', 'https://x.com/a/status/1') + .'

Texto entre os embeds.

' + .wpEmbedBlock('youtube', 'https://youtu.be/abc123') + .wpEmbedBlock('instagram', 'https://www.instagram.com/reel/XyZ/') + .wpEmbedBlock('reddit', 'https://www.reddit.com/r/a/comments/b/c/'); + + $html = EmbedProcessorService::processContent($content); + + expect($html) + ->toContain('embed-twitter') + ->toContain('embed-youtube') + ->toContain('embed-instagram') + ->toContain('embed-reddit') + ->toContain('Texto entre os embeds.'); +}); + +it('escapes the url it interpolates into attributes', function () { + // No "<" here on purpose: the pattern stops at it, so this is the shape + // that actually reaches htmlspecialchars(). + $html = EmbedProcessorService::processContent( + wpEmbedBlock('reddit', 'https://www.reddit.com/r/a"onmouseover="alert(1)') + ); + + expect($html) + ->toContain('embed-reddit') + ->toContain('"onmouseover="') + ->not->toContain('"onmouseover="alert(1)'); +}); + +it('leaves content without embeds untouched', function () { + $content = '

Um post comum.

Com titulo

'; + + expect(EmbedProcessorService::processContent($content))->toBe($content); +}); 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, + ]); + } +}