Initial baseline: LaraBlog core with plugin commerce surface.

Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
ak
2026-08-12 01:15:38 +08:00
commit 263b98b218
337 changed files with 31393 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature;
use App\Domain\Ai\Jobs\ModerateCommentJob;
use App\Domain\Ai\Jobs\OptimizeArticleContentJob;
use App\Models\Article;
use App\Models\Category;
use App\Models\Comment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class AiPipelineTest extends TestCase
{
use RefreshDatabase;
public function test_optimize_job_writes_stub_suggestions(): void
{
$user = User::factory()->create();
$category = Category::query()->create(['name' => 'c', 'display_order' => 0]);
$article = Article::query()->create([
'category_id' => $category->id,
'user_id' => $user->id,
'title' => 'T',
'content' => 'Hello world body',
'content_format' => 'markdown',
'published_at' => now(),
'visible' => true,
]);
(new OptimizeArticleContentJob($article->id))->handle(
app(\App\Contracts\LlmProvider::class),
app(\App\Settings\AiSettings::class),
);
$article->refresh();
$this->assertNotEmpty($article->ai_summary);
$this->assertIsArray($article->ai_suggestions);
}
public function test_moderate_job_approves_non_spam(): void
{
$user = User::factory()->create();
$category = Category::query()->create(['name' => 'c', 'display_order' => 0]);
$article = Article::query()->create([
'category_id' => $category->id,
'user_id' => $user->id,
'title' => 'T',
'content' => 'Body',
'content_format' => 'html',
'published_at' => now(),
'visible' => true,
]);
$comment = Comment::query()->create([
'article_id' => $article->id,
'author' => 'a',
'content' => 'nice post',
'moderation_status' => Comment::STATUS_PENDING_AI,
'published_at' => now(),
]);
(new ModerateCommentJob($comment->id))->handle(
app(\App\Contracts\LlmProvider::class),
app(\App\Settings\AiSettings::class),
);
$this->assertSame(Comment::STATUS_APPROVED, $comment->fresh()->moderation_status);
}
public function test_optimize_action_dispatches_to_ai_content_queue(): void
{
Queue::fake();
OptimizeArticleContentJob::dispatch(1);
Queue::assertPushedOn('ai-content', OptimizeArticleContentJob::class);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Article;
use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ApiV1Test extends TestCase
{
use RefreshDatabase;
public function test_api_meta_and_articles(): void
{
$user = User::factory()->create();
$category = Category::query()->create(['name' => 'API', 'display_order' => 0]);
$article = Article::query()->create([
'category_id' => $category->id,
'user_id' => $user->id,
'title' => 'API Post',
'content' => '## hi',
'content_format' => 'markdown',
'published_at' => now()->subMinute(),
'visible' => true,
]);
$this->getJson('/api/v1/meta')->assertOk()->assertJsonPath('api.version', 'v1');
$this->getJson('/api/v1/articles')->assertOk()->assertJsonPath('data.0.title', 'API Post');
$this->getJson('/api/v1/articles/'.$article->id)->assertOk()->assertJsonPath('data.id', $article->id);
$this->getJson('/api/v1/categories')->assertOk();
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class AuthFrontendTest extends TestCase
{
use RefreshDatabase;
public function test_register_login_and_profile_legacy_urls(): void
{
Role::findOrCreate('member');
$this->get('/login.shtml')->assertOk()->assertSee('登录');
$this->get('/reg.shtml')->assertOk()->assertSee('注册');
$this->post('/post.php', [
'action' => 'register',
'username' => 'alice',
'email' => 'alice@example.test',
'password' => 'secret12',
'password_confirmation' => 'secret12',
])->assertRedirect('/');
$this->assertAuthenticated();
$this->get('/index.php?action=profile')->assertOk()->assertSee('alice');
$this->post('/post.php', ['action' => 'logout'])->assertRedirect('/');
$this->assertGuest();
$this->post('/post.php', [
'action' => 'dologin',
'login' => 'alice',
'password' => 'secret12',
])->assertRedirect('/');
$this->assertAuthenticated();
}
public function test_legacy_md5_password_upgrades_on_login(): void
{
$user = User::factory()->create([
'username' => 'legacy',
'email' => 'legacy@example.test',
'password' => Hash::make(str()->password(32)),
'password_legacy' => md5('password'),
]);
$this->post('/post.php', [
'action' => 'dologin',
'login' => 'legacy',
'password' => 'password',
])->assertRedirect('/');
$this->assertAuthenticatedAs($user);
$user->refresh();
$this->assertNull($user->password_legacy);
$this->assertTrue(Hash::check('password', $user->password));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BlogFrontendTest extends TestCase
{
use RefreshDatabase;
public function test_home_and_article_legacy_urls(): void
{
$user = User::factory()->create();
$category = Category::query()->create(['name' => '随笔', 'display_order' => 0]);
$article = Article::query()->create([
'category_id' => $category->id,
'user_id' => $user->id,
'title' => 'Hello',
'content' => "## Hi\n\nworld",
'content_format' => 'markdown',
'published_at' => now()->subHour(),
'visible' => true,
]);
$this->get('/')
->assertOk()
->assertSee('Hello')
->assertSee('/themes/default/style.css', false);
$this->get('/show-'.$article->id.'.shtml')->assertOk()->assertSee('Hi');
$this->get('/tburl.php')->assertStatus(410);
$this->get('/llms.txt')->assertOk()->assertSee('Guidance for AI systems');
$this->get('/robots.txt')->assertOk()->assertSee('Sitemap:');
$this->get('/rss.xml')->assertOk();
$this->get('/sitemap.xml')->assertOk();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_health_endpoint_returns_successful_response(): void
{
$this->get('/up')->assertOk();
}
}
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Domain\Media\AttachmentStorageService;
use App\Models\Attachment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class LocalAttachmentServeTest extends TestCase
{
use RefreshDatabase;
protected string $attachmentsRoot;
protected function setUp(): void
{
parent::setUp();
$this->attachmentsRoot = storage_path('framework/testing/attachments-'.uniqid());
File::ensureDirectoryExists($this->attachmentsRoot);
config([
'filesystems.disks.attachments' => [
'driver' => 'local',
'root' => $this->attachmentsRoot,
'url' => rtrim((string) config('app.url'), '/').'/attachments-local',
'visibility' => 'public',
'throw' => true,
],
'larablog.attachments_disk' => 'attachments',
]);
}
protected function tearDown(): void
{
if (isset($this->attachmentsRoot) && is_dir($this->attachmentsRoot)) {
File::deleteDirectory($this->attachmentsRoot);
}
parent::tearDown();
}
public function test_local_driver_attachment_url_is_servable(): void
{
Storage::disk('attachments')->put('demo/hello.txt', 'hello');
$attachment = Attachment::query()->create([
'disk' => 'attachments',
'path' => 'demo/hello.txt',
'filename' => 'hello.txt',
'mime' => 'text/plain',
'size' => 5,
'visibility' => Attachment::VISIBILITY_PUBLIC,
'synced_at' => now(),
'downloads' => 0,
]);
$url = app(AttachmentStorageService::class)->publicUrl($attachment);
$this->assertStringContainsString('/attachments-local/', $url);
$path = parse_url($url, PHP_URL_PATH);
$response = $this->get($path);
$response->assertOk();
$this->assertStringContainsString('hello', $response->streamedContent());
$this->get('/attachment.php?id='.$attachment->id)->assertRedirect();
}
public function test_deleting_attachment_removes_disk_object(): void
{
Storage::disk('attachments')->put('orphan.bin', 'x');
$attachment = Attachment::query()->create([
'disk' => 'attachments',
'path' => 'orphan.bin',
'filename' => 'orphan.bin',
'size' => 1,
'visibility' => Attachment::VISIBILITY_PUBLIC,
'synced_at' => now(),
]);
$this->assertTrue(is_file($this->attachmentsRoot.'/orphan.bin'));
$attachment->delete();
$this->assertFalse(is_file($this->attachmentsRoot.'/orphan.bin'));
}
}
+382
View File
@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Domain\Blog\AccessDecision;
use App\Domain\Blog\ArticleAccess;
use App\Domain\Plugin\Hook;
use App\Domain\Plugin\PluginManager;
use App\Models\Article;
use App\Models\Category;
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Plugins\Larablog\PaidContent\Models\ArticleProduct;
use Plugins\Larablog\PaidContent\PluginServiceProvider as PaidContentProvider;
use Plugins\Larablog\Payment\Domain\OrderService;
use Plugins\Larablog\Payment\Domain\ProductType;
use Plugins\Larablog\Payment\Models\Order;
use Plugins\Larablog\Payment\Models\OrderItem;
use Plugins\Larablog\Payment\PluginServiceProvider as PaymentProvider;
use RuntimeException;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class PaidContentCommerceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Hook::flush();
$this->artisan('migrate', ['--path' => 'plugins/larablog/payment/database/migrations']);
$this->artisan('migrate', ['--path' => 'plugins/larablog/paid-content/database/migrations']);
app()->register(PaymentProvider::class);
app()->register(PaidContentProvider::class);
}
public function test_plugin_requires_payment_before_paid_content(): void
{
$manager = app(PluginManager::class);
$manager->syncDiscoveredPlugins();
Plugin::query()->where('name', 'larablog/payment')->update(['enabled' => false]);
Plugin::query()->where('name', 'larablog/paid-content')->update(['enabled' => false]);
$this->expectException(RuntimeException::class);
$manager->enable('larablog/paid-content');
}
public function test_password_article_blocked_on_api_and_rss(): void
{
[$article] = $this->makeArticle([
'content' => str_repeat('SECRET-BODY ', 20),
'read_password' => 's3cret',
'description' => 'short desc',
]);
$this->getJson('/api/v1/articles/'.$article->id)
->assertOk()
->assertJsonPath('data.access.status', AccessDecision::NEED_PASSWORD)
->assertJsonPath('data.content', null);
$rss = (string) $this->get('/rss.xml')->assertOk()->getContent();
$this->assertStringNotContainsString('SECRET-BODY', $rss);
$this->assertStringContainsString('short desc', $rss);
}
public function test_paid_article_teaser_then_purchase_unlocks_web(): void
{
$buyer = User::factory()->create();
$author = User::factory()->create();
[$article] = $this->makeArticle([
'user_id' => $author->id,
'content' => "## Intro\n\n".str_repeat('teaser ', 10)."UNIQUE_FULL_TOKEN ".str_repeat('tail ', 10),
]);
ArticleProduct::query()->create([
'article_id' => $article->id,
'enabled' => true,
'price' => '9.90',
'currency' => 'CNY',
'trial_mode' => 'chars',
'trial_value' => 40,
]);
$this->get('/show-'.$article->id.'.shtml')
->assertOk()
->assertSee(__('frontend.article.paywall_title'))
->assertDontSee('UNIQUE_FULL_TOKEN');
$this->getJson('/api/v1/articles/'.$article->id)
->assertOk()
->assertJsonPath('data.access.status', AccessDecision::NEED_PURCHASE)
->assertJsonPath('data.content', null);
$orders = app(OrderService::class);
$order = $orders->createOrder(
$buyer,
ProductType::ARTICLE,
(int) $article->id,
(string) $article->title,
'9.90',
);
$orders->markPaid($order, ['source' => 'test']);
$this->actingAs($buyer)
->get('/show-'.$article->id.'.shtml')
->assertOk()
->assertSee('UNIQUE_FULL_TOKEN')
->assertDontSee(__('frontend.article.paywall_title'));
}
public function test_stub_rejects_duplicate_entitlement_order(): void
{
$user = User::factory()->create();
[$article] = $this->makeArticle();
$orders = app(OrderService::class);
$first = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00');
$orders->markPaid($first);
$this->expectException(RuntimeException::class);
$orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00');
}
public function test_guest_checkout_redirects_to_login_not_500(): void
{
[$article] = $this->makeArticle();
ArticleProduct::query()->create([
'article_id' => $article->id,
'enabled' => true,
'price' => '1.00',
'currency' => 'CNY',
'trial_mode' => 'chars',
'trial_value' => 20,
]);
$this->get('/plugins/payment/checkout?'.http_build_query([
'product_type' => ProductType::ARTICLE,
'product_id' => $article->id,
]))->assertRedirect('/login.shtml');
}
public function test_home_list_does_not_leak_paid_body(): void
{
[$article] = $this->makeArticle([
'description' => null,
'content' => str_repeat('preview ', 30)."\n\nHOME_LEAK_TOKEN\n\n".str_repeat('tail ', 30),
]);
ArticleProduct::query()->create([
'article_id' => $article->id,
'enabled' => true,
'price' => '2.00',
'currency' => 'CNY',
'trial_mode' => 'chars',
'trial_value' => 20,
]);
$this->get('/')
->assertOk()
->assertDontSee('HOME_LEAK_TOKEN');
}
public function test_cannot_disable_payment_while_paid_content_enabled(): void
{
$manager = app(PluginManager::class);
$manager->syncDiscoveredPlugins();
$manager->enable('larablog/payment');
$manager->enable('larablog/paid-content');
$this->expectException(RuntimeException::class);
$manager->disable('larablog/payment');
}
public function test_teaser_never_returns_full_body(): void
{
[$article] = $this->makeArticle([
'description' => null,
'content' => 'TEASER_HEAD short paid body ending with TEASER_TAIL_TOKEN',
]);
ArticleProduct::query()->create([
'article_id' => $article->id,
'enabled' => true,
'price' => '1.00',
'currency' => 'CNY',
'trial_mode' => 'chars',
'trial_value' => 9999,
]);
$this->get('/show-'.$article->id.'.shtml')
->assertOk()
->assertSee(__('frontend.article.paywall_title'))
->assertDontSee('TEASER_TAIL_TOKEN');
$this->getJson('/api/v1/articles/'.$article->id)
->assertOk()
->assertJsonPath('data.access.status', AccessDecision::NEED_PURCHASE)
->assertJsonMissing(['teaser_html' => 'TEASER_TAIL_TOKEN']);
}
public function test_access_filter_can_only_tighten(): void
{
[$article] = $this->makeArticle();
Hook::listen('article.access', fn (AccessDecision $decision): AccessDecision => $decision->tightenWith(
AccessDecision::needPurchase('teaser', '/buy')
));
Hook::listen('article.access', fn (AccessDecision $decision): AccessDecision => AccessDecision::allow());
Hook::listen('article.access', fn (): string => 'not-a-decision');
$decision = app(ArticleAccess::class)->resolve($article, null, []);
$this->assertSame(AccessDecision::NEED_PURCHASE, $decision->status);
}
public function test_plugin_docs_cannot_escape_plugin_directory(): void
{
$root = storage_path('framework/testing/plugins-'.uniqid());
$pluginDir = $root.'/acme/evil';
mkdir($pluginDir, 0777, true);
file_put_contents($pluginDir.'/plugin.json', json_encode([
'name' => 'acme/evil',
'title' => 'Evil',
'version' => '1.0.0',
'docs' => '../../../../.env',
]));
config(['larablog.plugin_path' => $root]);
$manager = app(PluginManager::class);
try {
$this->assertNull($manager->docsPath('acme/evil'));
$this->assertNull($manager->readDocs('acme/evil'));
} finally {
// Only remove the temp tree this test created.
unlink($pluginDir.'/plugin.json');
rmdir($pluginDir);
rmdir($root.'/acme');
rmdir($root);
}
}
public function test_duplicate_pending_order_is_reused(): void
{
$user = User::factory()->create();
[$article] = $this->makeArticle();
$orders = app(OrderService::class);
$first = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00');
$second = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00');
$this->assertSame($first->id, $second->id);
}
public function test_reused_pending_order_is_repriced(): void
{
$user = User::factory()->create();
[$article] = $this->makeArticle();
$orders = app(OrderService::class);
$orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'Old', '1.00');
$reused = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'New', '9.90');
$this->assertSame('9.90', (string) $reused->amount);
$this->assertSame('9.90', (string) $reused->items->first()->amount);
$this->assertSame('New', $reused->items->first()->title);
$this->assertSame(1, Order::query()->count());
}
public function test_mark_paid_rejects_product_owned_by_another_order(): void
{
$user = User::factory()->create();
[$article] = $this->makeArticle();
$orders = app(OrderService::class);
$paid = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00');
$orders->markPaid($paid);
$duplicate = Order::query()->create([
'user_id' => $user->id,
'status' => Order::STATUS_PENDING,
'amount' => '1.00',
'currency' => 'CNY',
'gateway' => 'stub',
]);
OrderItem::query()->create([
'order_id' => $duplicate->id,
'product_type' => ProductType::ARTICLE,
'product_id' => $article->id,
'title' => 'T',
'amount' => '1.00',
]);
$this->expectException(RuntimeException::class);
$orders->markPaid($duplicate);
}
public function test_checkout_rejects_unpublished_paid_article(): void
{
$user = User::factory()->create();
[$article] = $this->makeArticle(['visible' => false]);
ArticleProduct::query()->create([
'article_id' => $article->id,
'enabled' => true,
'price' => '5.00',
'currency' => 'CNY',
'trial_mode' => 'chars',
'trial_value' => 20,
]);
$this->actingAs($user)
->get('/plugins/payment/checkout?'.http_build_query([
'product_type' => ProductType::ARTICLE,
'product_id' => $article->id,
]))
->assertStatus(422);
$this->assertSame(0, Order::query()->count());
}
public function test_author_and_admin_bypass_paywall(): void
{
Role::findOrCreate('admin');
$author = User::factory()->create();
$admin = User::factory()->create();
$admin->assignRole('admin');
[$article] = $this->makeArticle([
'user_id' => $author->id,
'content' => str_repeat('full-access-body ', 30),
]);
ArticleProduct::query()->create([
'article_id' => $article->id,
'enabled' => true,
'price' => '3.00',
'currency' => 'CNY',
'trial_mode' => 'chars',
'trial_value' => 10,
]);
$access = app(ArticleAccess::class);
$this->assertTrue($access->resolve($article, $author)->isAllow());
$this->assertTrue($access->resolve($article, $admin)->isAllow());
$this->assertSame(AccessDecision::NEED_PURCHASE, $access->resolve($article, null)->status);
}
/**
* @param array<string, mixed> $overrides
* @return array{0: Article, 1: User, 2: Category}
*/
protected function makeArticle(array $overrides = []): array
{
$userId = $overrides['user_id'] ?? null;
$user = $userId
? User::query()->findOrFail($userId)
: User::factory()->create();
$category = Category::query()->create(['name' => 'Cat', 'display_order' => 0]);
$payload = array_merge([
'category_id' => $category->id,
'user_id' => $user->id,
'title' => 'Paid Post',
'content' => 'Hello body',
'content_format' => 'markdown',
'published_at' => now()->subMinute(),
'visible' => true,
], $overrides);
$article = Article::query()->create($payload);
return [$article, $user, $category];
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\Attachment;
use App\Models\Comment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class SablogImportTest extends TestCase
{
use RefreshDatabase;
protected string $fixtureDb;
protected function setUp(): void
{
parent::setUp();
Storage::fake('attachments');
Config::set('filesystems.disks.attachments', [
'driver' => 'local',
'root' => Storage::disk('attachments')->path(''),
'throw' => true,
]);
$this->fixtureDb = storage_path('framework/testing/sablog-fixture.sqlite');
@mkdir(dirname($this->fixtureDb), 0777, true);
if (is_file($this->fixtureDb)) {
unlink($this->fixtureDb);
}
$pdo = new \PDO('sqlite:'.$this->fixtureDb);
$pdo->exec(file_get_contents(base_path('tests/fixtures/sablog/schema.sql')));
$pdo->exec(file_get_contents(base_path('tests/fixtures/sablog/seed.sql')));
Config::set('database.connections.sablog', [
'driver' => 'sqlite',
'database' => $this->fixtureDb,
'prefix' => '',
'foreign_key_constraints' => true,
]);
DB::purge('sablog');
}
protected function tearDown(): void
{
if (isset($this->fixtureDb) && is_file($this->fixtureDb)) {
unlink($this->fixtureDb);
}
parent::tearDown();
}
public function test_import_raw_preserves_ids_and_skips_legacy_tables(): void
{
$this->artisan('sablog:import', [
'--connection' => 'sablog',
'--mode' => 'raw',
'--attachments' => base_path('tests/fixtures/sablog/attachments'),
'--disk' => 'attachments',
])->assertSuccessful();
$this->assertDatabaseHas('articles', ['id' => 10, 'title' => 'Fixture Post', 'content_format' => 'html']);
$this->assertDatabaseHas('categories', ['id' => 1, 'name' => '随笔']);
$this->assertDatabaseHas('comments', ['id' => 5, 'moderation_status' => Comment::STATUS_APPROVED]);
$this->assertDatabaseHas('users', ['id' => 1, 'username' => 'admin']);
$article = Article::query()->findOrFail(10);
$this->assertStringContainsString('[attach=1]', $article->content);
$attachment = Attachment::query()->findOrFail(1);
$this->assertNotNull($attachment->synced_at);
$this->assertTrue(Storage::disk('attachments')->exists($attachment->path));
$user = User::query()->findOrFail(1);
$this->assertSame(md5('password'), $user->password_legacy);
}
public function test_import_markdown_rewrites_attach_embeds(): void
{
$this->artisan('sablog:import', [
'--connection' => 'sablog',
'--mode' => 'markdown',
'--attachments' => base_path('tests/fixtures/sablog/attachments'),
'--disk' => 'attachments',
])->assertSuccessful();
$article = Article::query()->findOrFail(10);
$this->assertSame('markdown', $article->content_format);
$this->assertStringContainsString('attach:1', $article->content);
$this->assertStringNotContainsString('[attach=1]', $article->content);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Tests\Unit;
use App\Domain\Blog\AttachEmbed;
use App\Domain\Blog\ContentFormat;
use App\Domain\Blog\ContentRenderer;
use Tests\TestCase;
class AttachEmbedTest extends TestCase
{
public function test_legacy_token_converts_to_markdown_safe_reference(): void
{
$md = app(AttachEmbed::class)->legacyToMarkdownReference('see [attach=12] please');
$this->assertStringContainsString('(attach:12)', $md);
$this->assertStringNotContainsString('[attach=12]', $md);
}
public function test_markdown_render_expands_attach_protocol_to_entry_url(): void
{
$html = app(ContentRenderer::class)->toHtml(
"![x](attach:42)\n",
ContentFormat::MARKDOWN,
);
$this->assertStringContainsString('/attachment.php?id=42', $html);
$this->assertStringNotContainsString('attach:42', $html);
}
public function test_html_render_hydrates_legacy_attach_token(): void
{
$html = app(ContentRenderer::class)->toHtml(
'<p>file [attach=7]</p>',
ContentFormat::HTML,
);
$this->assertStringContainsString('/attachment.php?id=7', $html);
$this->assertStringNotContainsString('[attach=7]', $html);
}
public function test_html_img_src_attach_attribute_is_not_nested(): void
{
$html = app(ContentRenderer::class)->toHtml(
'<p><img src="[attach=1]" alt="pic"></p>',
ContentFormat::HTML,
);
$this->assertStringContainsString('src="'.url('/attachment.php?id=1').'"', $html);
$this->assertStringNotContainsString('src="<img', $html);
$this->assertStringNotContainsString('[attach=1]', $html);
}
public function test_markdown_import_conversion_protects_attach_tokens(): void
{
$md = app(ContentRenderer::class)->convertImportedHtmlToMarkdown(
'<p><img src="[attach=1]" alt="pic"> and [attach=2]</p>'
);
$this->assertStringContainsString('(attach:1)', $md);
$this->assertStringContainsString('(attach:2)', $md);
$this->assertStringNotContainsString('![](![', $md);
$this->assertStringNotContainsString('\[attach=', $md);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Tests\Unit;
use App\Domain\Blog\ContentFormat;
use App\Domain\Blog\ContentRenderer;
use Tests\TestCase;
class ContentRendererTest extends TestCase
{
public function test_markdown_renders_to_html(): void
{
$html = app(ContentRenderer::class)->toHtml("# Hello\n\n**world**", ContentFormat::MARKDOWN);
$this->assertMatchesRegularExpression('/<h1[^>]*>Hello<\/h1>/', $html);
$this->assertStringContainsString('<strong>world</strong>', $html);
}
public function test_html_format_passes_through_purifier(): void
{
$html = app(ContentRenderer::class)->toHtml(
'<p>ok</p><script>alert(1)</script>',
ContentFormat::HTML,
);
$this->assertStringContainsString('<p>ok</p>', $html);
$this->assertStringNotContainsString('<script>', $html);
}
public function test_html_to_markdown_roundtrip_basic(): void
{
$md = app(ContentRenderer::class)->htmlToMarkdown('<h2>Title</h2><p>Hello <strong>x</strong></p>');
$this->assertStringContainsString('Title', $md);
$this->assertStringContainsString('Hello', $md);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Domain\Blog\ContentFormat;
use App\Domain\Blog\ContentRenderer;
use Tests\TestCase;
class ContentTocTest extends TestCase
{
public function test_markdown_headings_produce_toc_and_ids(): void
{
$markdown = <<<'MD'
Intro paragraph.
## First Section
Hello.
### Nested
More.
## Second Section
End.
MD;
$result = app(ContentRenderer::class)->render($markdown, ContentFormat::MARKDOWN);
$this->assertCount(3, $result['toc']);
$this->assertSame('First Section', $result['toc'][0]['text']);
$this->assertSame(2, $result['toc'][0]['level']);
$this->assertSame('Nested', $result['toc'][1]['text']);
$this->assertSame(3, $result['toc'][1]['level']);
$this->assertStringContainsString('id="', $result['html']);
$this->assertStringContainsString($result['toc'][0]['id'], $result['html']);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Domain\Theme\ThemeManager;
use App\Domain\Theme\ThemeSlot;
use App\Domain\Theme\ThemeSlotReport;
use Tests\TestCase;
class ThemeSlotReportTest extends TestCase
{
public function test_star_declares_all_standard_slots(): void
{
$this->assertSame(ThemeSlot::all(), ThemeSlotReport::normalizeDeclared('*'));
}
public function test_default_and_example_themes_report_full(): void
{
$manager = app(ThemeManager::class);
foreach (['default', 'example'] as $slug) {
$report = $manager->slotReport($slug);
$this->assertSame('full', $report['status'], $slug.' should be full: '.json_encode($report));
$this->assertSame([], $report['missing_standard']);
}
}
public function test_undeclared_manifest_status(): void
{
$report = ThemeSlotReport::analyze('tmp', [], base_path('themes/default'));
$this->assertSame('undeclared', $report['status']);
}
}
+8
View File
@@ -0,0 +1,8 @@
# sablog 最小样例包
用于 `sablog:import` 回归测试,不依赖真实 sablog 库。
- `schema.sql` / `seed.sql`SQLite 源库结构与数据
- `attachments/`:本地附件目录(与 `filepath` 对齐)
测试入口:`tests/Feature/SablogImportTest.php`
+1
View File
@@ -0,0 +1 @@
hello
+103
View File
@@ -0,0 +1,103 @@
-- Minimal sablog-like schema for import regression (SQLite).
CREATE TABLE sablog_users (
userid INTEGER PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL,
url TEXT,
logincount INTEGER DEFAULT 0,
loginip TEXT,
logintime INTEGER,
regip TEXT,
regdateline INTEGER
);
CREATE TABLE sablog_categories (
cid INTEGER PRIMARY KEY,
name TEXT NOT NULL,
displayorder INTEGER DEFAULT 0,
articles INTEGER DEFAULT 0
);
CREATE TABLE sablog_articles (
articleid INTEGER PRIMARY KEY,
cid INTEGER NOT NULL,
uid INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
keywords TEXT,
dateline INTEGER,
views INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
stick INTEGER DEFAULT 0,
visible INTEGER DEFAULT 1,
closecomment INTEGER DEFAULT 0,
readpassword TEXT,
attachments TEXT
);
CREATE TABLE sablog_comments (
commentid INTEGER PRIMARY KEY,
articleid INTEGER NOT NULL,
author TEXT NOT NULL,
url TEXT,
content TEXT NOT NULL,
ipaddress TEXT,
visible INTEGER DEFAULT 1,
dateline INTEGER
);
CREATE TABLE sablog_tags (
tagid INTEGER PRIMARY KEY,
tag TEXT NOT NULL,
usenum INTEGER DEFAULT 0,
aids TEXT
);
CREATE TABLE sablog_links (
linkid INTEGER PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
note TEXT,
displayorder INTEGER DEFAULT 0,
visible INTEGER DEFAULT 1
);
CREATE TABLE sablog_stylevars (
stylevarid INTEGER PRIMARY KEY,
title TEXT NOT NULL,
value TEXT,
visible INTEGER DEFAULT 1
);
CREATE TABLE sablog_attachments (
attachmentid INTEGER PRIMARY KEY,
articleid INTEGER,
filepath TEXT NOT NULL,
filename TEXT,
filetype TEXT,
filesize INTEGER DEFAULT 0,
downloads INTEGER DEFAULT 0,
thumb_filepath TEXT
);
CREATE TABLE sablog_trackbacks (
trackbackid INTEGER PRIMARY KEY,
articleid INTEGER,
title TEXT
);
CREATE TABLE sablog_trackbacklog (
id INTEGER PRIMARY KEY,
articleid INTEGER
);
CREATE TABLE sablog_searchindex (
id INTEGER PRIMARY KEY,
keywords TEXT
);
CREATE TABLE sablog_sessions (
sid TEXT PRIMARY KEY,
uid INTEGER
);
+45
View File
@@ -0,0 +1,45 @@
INSERT INTO sablog_users (userid, username, password, url, logincount, loginip, logintime, regip, regdateline)
VALUES (1, 'admin', '5f4dcc3b5aa765d61d8327deb882cf99', 'https://example.test', 3, '127.0.0.1', 1720000000, '127.0.0.1', 1700000000);
-- password = md5('password')
INSERT INTO sablog_categories (cid, name, displayorder, articles)
VALUES (1, '随笔', 0, 1);
INSERT INTO sablog_articles (articleid, cid, uid, title, content, description, keywords, dateline, views, comments, stick, visible, closecomment, readpassword, attachments)
VALUES (
10,
1,
1,
'Fixture Post',
'<p>Hello <img src="[attach=1]" /> world [attach=1]</p>',
'desc',
'kw',
1720000000,
12,
1,
0,
1,
0,
'',
NULL
);
INSERT INTO sablog_comments (commentid, articleid, author, url, content, ipaddress, visible, dateline)
VALUES (5, 10, 'guest', NULL, 'nice post', '127.0.0.1', 1, 1720000100);
INSERT INTO sablog_tags (tagid, tag, usenum, aids)
VALUES (2, 'laravel', 1, '10');
INSERT INTO sablog_links (linkid, name, url, note, displayorder, visible)
VALUES (3, 'Laravel', 'https://laravel.com', 'fw', 0, 1);
INSERT INTO sablog_stylevars (stylevarid, title, value, visible)
VALUES (4, 'footer', 'Powered by sablog fixture', 1);
INSERT INTO sablog_attachments (attachmentid, articleid, filepath, filename, filetype, filesize, downloads, thumb_filepath)
VALUES (1, 10, '2024/08/demo.txt', 'demo.txt', 'text/plain', 5, 0, NULL);
INSERT INTO sablog_trackbacks (trackbackid, articleid, title) VALUES (1, 10, 'spam');
INSERT INTO sablog_trackbacklog (id, articleid) VALUES (1, 10);
INSERT INTO sablog_searchindex (id, keywords) VALUES (1, 'old');
INSERT INTO sablog_sessions (sid, uid) VALUES ('abc', 1);