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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -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(
|
||||
"\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(';
|
||||
$this->assertStringNotContainsString('\[attach=', $md);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# sablog 最小样例包
|
||||
|
||||
用于 `sablog:import` 回归测试,不依赖真实 sablog 库。
|
||||
|
||||
- `schema.sql` / `seed.sql`:SQLite 源库结构与数据
|
||||
- `attachments/`:本地附件目录(与 `filepath` 对齐)
|
||||
|
||||
测试入口:`tests/Feature/SablogImportTest.php`
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
Vendored
+103
@@ -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
|
||||
);
|
||||
Vendored
+45
@@ -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);
|
||||
Reference in New Issue
Block a user