From b7a70f74e5acac5d4fd43426646d69ae659f160a Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sun, 10 May 2026 12:14:24 +0900 Subject: [PATCH] Add DocumentTranslation model with renderMarkdown and search scope Includes a minimal Document::translations() HasMany relation so that DocumentFactory's afterCreating callback (which calls $document->translations()->count()) works. The full Document model refactor (accessors, fallback helpers, default-translation accessor) lands in Task 4. --- src/app/Models/Document.php | 9 +++ src/app/Models/DocumentTranslation.php | 72 +++++++++++++++++++ .../Unit/Models/DocumentTranslationTest.php | 55 ++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/app/Models/DocumentTranslation.php create mode 100644 src/tests/Unit/Models/DocumentTranslationTest.php diff --git a/src/app/Models/Document.php b/src/app/Models/Document.php index 9de8f9a..ae17803 100644 --- a/src/app/Models/Document.php +++ b/src/app/Models/Document.php @@ -281,6 +281,15 @@ public function recentByUsers(): HasManyThrough ); } + /** + * Translations of this document, one per locale. + * (Other relation/accessor refactor lands in Task 4.) + */ + public function translations(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(DocumentTranslation::class); + } + /** * ディレクトリパスを取得 * diff --git a/src/app/Models/DocumentTranslation.php b/src/app/Models/DocumentTranslation.php new file mode 100644 index 0000000..ef80dae --- /dev/null +++ b/src/app/Models/DocumentTranslation.php @@ -0,0 +1,72 @@ +belongsTo(Document::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * Full-text search scope. Falls back to LIKE on non-MySQL drivers + * (notably SQLite in tests, which lacks FULLTEXT). + */ + public function scopeSearch(Builder $query, string $term): Builder + { + if ($query->getConnection()->getDriverName() === 'mysql') { + return $query->whereRaw( + 'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', + [$term] + ); + } + + return $query->where(function (Builder $q) use ($term) { + $like = '%' . $term . '%'; + $q->where('title', 'like', $like)->orWhere('content', 'like', $like); + }); + } + + public static function renderMarkdown(string $markdown): string + { + $converter = new CommonMarkConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + $converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension()); + $converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension()); + + return $converter->convert($markdown)->getContent(); + } +} diff --git a/src/tests/Unit/Models/DocumentTranslationTest.php b/src/tests/Unit/Models/DocumentTranslationTest.php new file mode 100644 index 0000000..cf01aa2 --- /dev/null +++ b/src/tests/Unit/Models/DocumentTranslationTest.php @@ -0,0 +1,55 @@ +create(); + $translation = $doc->translations()->first(); + + $this->assertInstanceOf(Document::class, $translation->document); + $this->assertSame($doc->id, $translation->document->id); + } + + public function test_unique_document_locale_constraint(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + + $this->expectException(QueryException::class); + + DocumentTranslation::create([ + 'document_id' => $doc->id, + 'locale' => 'en', + 'title' => 'Duplicate', + 'content' => 'x', + 'rendered_html' => '

x

', + ]); + } + + public function test_cascade_delete_when_document_deleted(): void + { + $doc = Document::factory()->create(); + $translationId = $doc->translations()->first()->id; + + $doc->forceDelete(); + + $this->assertNull(DocumentTranslation::find($translationId)); + } + + public function test_render_markdown_converts_basic_markdown(): void + { + $html = DocumentTranslation::renderMarkdown('# Hello'); + $this->assertStringContainsString('

', $html); + $this->assertStringContainsString('Hello', $html); + } +}