diff --git a/tests/Feature/Actions/Extract/ExtractActionTest.php b/tests/Feature/Actions/Extract/ExtractActionTest.php
new file mode 100644
index 0000000..b1c7552
--- /dev/null
+++ b/tests/Feature/Actions/Extract/ExtractActionTest.php
@@ -0,0 +1,51 @@
+shouldReceive('__invoke')->once();
+ $this->app->instance(Handler::class, $mock);
+
+ $portalHandler = \Mockery::mock(HandlerInterface::class);
+ $portalHandler->shouldReceive('__invoke')->once();
+ $this->app->instance(\App\Actions\Extract\Portal\Handler::class, $portalHandler);
+
+ $twitransHandler = \Mockery::mock(HandlerInterface::class);
+ $twitransHandler->shouldReceive('__invoke')->once();
+ $this->app->instance(\App\Actions\Extract\Twitrans\Handler::class, $twitransHandler);
+
+ $extractAction = app(ExtractAction::class);
+ $extractAction(null, new NullLogger);
+ }
+
+ public function test_invokes_specific_handler_when_site_provided(): void
+ {
+ $mock = \Mockery::mock(HandlerInterface::class);
+ $mock->shouldReceive('__invoke')->once();
+ $this->app->instance(Handler::class, $mock);
+
+ $portalHandler = \Mockery::mock(HandlerInterface::class);
+ $portalHandler->shouldReceive('__invoke')->never();
+ $this->app->instance(\App\Actions\Extract\Portal\Handler::class, $portalHandler);
+
+ $twitransHandler = \Mockery::mock(HandlerInterface::class);
+ $twitransHandler->shouldReceive('__invoke')->never();
+ $this->app->instance(\App\Actions\Extract\Twitrans\Handler::class, $twitransHandler);
+
+ $extractAction = app(ExtractAction::class);
+ $extractAction(SiteName::Japan, new NullLogger);
+ }
+}
diff --git a/tests/Feature/Actions/Extract/Japan/ExtractContentsTest.php b/tests/Feature/Actions/Extract/Japan/ExtractContentsTest.php
new file mode 100644
index 0000000..d56c04d
--- /dev/null
+++ b/tests/Feature/Actions/Extract/Japan/ExtractContentsTest.php
@@ -0,0 +1,28 @@
+make([
+ 'url' => 'https://japanese.simutrans.com/index.php?addon128%2fTest',
+ 'html' => '
Awesome Addon - Simutrans日本語化・解説This is an awesome addon description.
',
+ ]);
+
+ $extractContents = new ExtractContents;
+ $result = $extractContents($rawPage);
+
+ $this->assertSame('Awesome Addon', $result['title']);
+ $this->assertSame('This is an awesome addon description.', $result['text']);
+ $this->assertEquals([PakSlug::Pak128], $result['paks']);
+ }
+}
diff --git a/tests/Feature/Actions/Extract/Japan/ExtractLastModifiedTest.php b/tests/Feature/Actions/Extract/Japan/ExtractLastModifiedTest.php
new file mode 100644
index 0000000..a4f9b33
--- /dev/null
+++ b/tests/Feature/Actions/Extract/Japan/ExtractLastModifiedTest.php
@@ -0,0 +1,26 @@
+make([
+ 'html' => 'Last-modified: 2023-05-15 14:30:00 (月)
',
+ ]);
+
+ $extractLastModified = new ExtractLastModified;
+ $date = $extractLastModified($rawPage);
+
+ $this->assertInstanceOf(CarbonImmutable::class, $date);
+ $this->assertSame('2023-05-15 14:30:00', $date->format('Y-m-d H:i:s'));
+ }
+}
diff --git a/tests/Feature/Actions/Extract/Portal/ExtractContentsTest.php b/tests/Feature/Actions/Extract/Portal/ExtractContentsTest.php
new file mode 100644
index 0000000..72f657c
--- /dev/null
+++ b/tests/Feature/Actions/Extract/Portal/ExtractContentsTest.php
@@ -0,0 +1,78 @@
+ [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]]);
+ Schema::connection('portal')->create('file_infos', function (Blueprint $blueprint): void {
+ $blueprint->id();
+ $blueprint->integer('attachment_id');
+ $blueprint->text('data')->nullable();
+ $blueprint->timestamps();
+ });
+ }
+
+ public function test_extracts_title_text_and_pak(): void
+ {
+ $fileInfo = new FileInfo;
+ $fileInfo->attachment_id = 123;
+ $fileInfo->data = 'file content info';
+ $fileInfo->save();
+
+ $article = new Article([
+ 'title' => 'Portal Addon Title',
+ 'post_type' => ArticlePostType::AddonPost,
+ 'contents' => [
+ 'description' => 'A nice addon.',
+ 'thanks' => 'Thanks to the author.',
+ 'license' => 'MIT',
+ 'file' => 123,
+ ],
+ ]);
+
+ $tag = new Tag(['name' => 'Train', 'description' => 'A train addon']);
+ $article->setRelation('tags', collect([$tag]));
+
+ $category = new Category(['slug' => '128-japan']);
+ $article->setRelation('categories', collect([$category]));
+
+ $extractContents = new ExtractContents(new FindFileInfo);
+ $result = $extractContents($article);
+
+ $this->assertSame('Portal Addon Title', $result['title']);
+ $this->assertStringContainsString('A nice addon.', $result['text']);
+ $this->assertStringContainsString('Thanks to the author.', $result['text']);
+ $this->assertStringContainsString('MIT', $result['text']);
+ $this->assertStringContainsString('Train', $result['text']);
+ $this->assertStringContainsString('A train addon', $result['text']);
+ $this->assertStringContainsString('file content info', $result['text']);
+
+ $this->assertEquals([PakSlug::Pak128Jp], $result['paks']);
+ }
+}
diff --git a/tests/Feature/Actions/Extract/Twitrans/ExtractContentsTest.php b/tests/Feature/Actions/Extract/Twitrans/ExtractContentsTest.php
new file mode 100644
index 0000000..affe461
--- /dev/null
+++ b/tests/Feature/Actions/Extract/Twitrans/ExtractContentsTest.php
@@ -0,0 +1,28 @@
+make([
+ 'url' => 'https://wikiwiki.jp/twitrans/addon/pak128.japan/test',
+ 'html' => 'Twitrans Addon - Simutrans的な実験室 Wiki*This is a twitrans addon description.
',
+ ]);
+
+ $extractContents = new ExtractContents;
+ $result = $extractContents($rawPage);
+
+ $this->assertSame('Twitrans Addon', $result['title']);
+ $this->assertSame('This is a twitrans addon description.', $result['text']);
+ $this->assertEquals([PakSlug::Pak128Jp], $result['paks']);
+ }
+}
diff --git a/tests/Feature/Actions/Extract/Twitrans/ExtractLastModifiedTest.php b/tests/Feature/Actions/Extract/Twitrans/ExtractLastModifiedTest.php
new file mode 100644
index 0000000..32efa3e
--- /dev/null
+++ b/tests/Feature/Actions/Extract/Twitrans/ExtractLastModifiedTest.php
@@ -0,0 +1,26 @@
+make([
+ 'html' => 'Last-modified: 2023-11-20 10:00:00 (月)
',
+ ]);
+
+ $extractLastModified = new ExtractLastModified;
+ $date = $extractLastModified($rawPage);
+
+ $this->assertInstanceOf(CarbonImmutable::class, $date);
+ $this->assertSame('2023-11-20 10:00:00', $date->format('Y-m-d H:i:s'));
+ }
+}