392 lines
22 KiB
PHP
392 lines
22 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Services\ArticleResponseCacheProfile;
|
|
use App\Services\Editorial\ContentRenderer;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
|
|
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
|
|
class EditorialApiTest extends TestCase
|
|
{
|
|
private string $credential;
|
|
|
|
private string $token;
|
|
|
|
private array $uploaded = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
|
|
'editorial.enabled' => true, 'editorial.base_url' => 'http://localhost',
|
|
'scout.driver' => 'collection', 'responsecache.enabled' => false, 'cache.default' => 'array']);
|
|
DB::purge('sqlite');
|
|
$this->credential = tempnam(sys_get_temp_dir(), 'editorial-test-');
|
|
$this->token = 'fna_ed_'.str_repeat('a', 64);
|
|
file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'testing', 'base_url' => 'http://localhost', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]]));
|
|
config(['editorial.credential_file' => $this->credential]);
|
|
Http::preventStrayRequests();
|
|
Schema::create('categories', function (Blueprint $t) {
|
|
$t->id();
|
|
$t->string('sSlug');
|
|
$t->string('sName');
|
|
$t->integer('iParentId')->default(0);
|
|
$t->integer('iStatus')->default(1);
|
|
$t->integer('iSort')->default(0);
|
|
$t->integer('iShowChildren')->default(0);
|
|
$t->string('sHtmlClass')->nullable();
|
|
$t->text('aOpt')->nullable();
|
|
});
|
|
Schema::create('articles', function (Blueprint $t) {
|
|
$t->id();
|
|
$t->integer('iCategoryId');
|
|
$t->integer('iUserId')->default(1);
|
|
$t->string('sSlug')->unique();
|
|
$t->string('sTitle');
|
|
$t->string('sTitleSub')->nullable();
|
|
$t->text('sContent');
|
|
$t->string('sThumb')->nullable();
|
|
$t->integer('iStatus')->default(1);
|
|
$t->boolean('bPageThumb')->default(false);
|
|
foreach (['iViews', 'iLikeCount', 'i7like', 'i365like', 'iIsTop'] as $field) {
|
|
$t->integer($field)->default(0);
|
|
}
|
|
$t->timestamp('sLastLikedAt')->nullable();
|
|
$t->timestamps();
|
|
});
|
|
Schema::create('tags', function (Blueprint $t) {
|
|
$t->id();
|
|
$t->string('sSlug');
|
|
$t->string('sName');
|
|
$t->integer('iArticleCount')->default(0);
|
|
});
|
|
Schema::create('article_tag', function (Blueprint $t) {
|
|
$t->integer('iArticleId');
|
|
$t->integer('iTagId');
|
|
});
|
|
(require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up();
|
|
(require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up();
|
|
DB::table('categories')->insert([
|
|
['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'],
|
|
['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'],
|
|
]);
|
|
DB::table('tags')->insert(['id' => 1, 'sSlug' => 'clash', 'sName' => 'Clash']);
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token]);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
@unlink($this->credential);
|
|
foreach ($this->uploaded as $sha) {
|
|
@unlink(app(ContentRenderer::class)->privateImage($sha));
|
|
foreach (glob(public_path('images/editorial/'.$sha.'.*')) as $path) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function item(string $key = 'example', int $revision = 0): array
|
|
{
|
|
return ['content_key' => $key, 'expected_revision' => $revision, 'slug' => $key, 'title' => '教程标题',
|
|
'summary' => '一篇操作教程', 'category_slug' => 'windows', 'body' => '<h2 id="install">安装</h2>'."\n\n正文", 'tag_ids' => [1]];
|
|
}
|
|
|
|
private function draft(array $items, ?string $id = null): array
|
|
{
|
|
return $this->postJson('/api/editorial/v1/drafts', ['request_id' => $id ?? (string) Str::uuid(), 'articles' => $items])->assertCreated()->json();
|
|
}
|
|
|
|
private function publish(array $draft, ?string $id = null)
|
|
{
|
|
return $this->postJson('/api/editorial/v1/drafts/'.$draft['draft_id'].'/publish', ['request_id' => $id ?? (string) Str::uuid(), 'expected_hash' => $draft['hash']]);
|
|
}
|
|
|
|
public function test_new_article_like_defaults_are_frozen_and_updates_preserve_progress(): void
|
|
{
|
|
$request = (string) Str::uuid();
|
|
$d = $this->draft([$this->item()], $request);
|
|
$settings = $d['articles'][0]['like_settings'];
|
|
$this->assertGreaterThanOrEqual(5, $settings['seven_day']);
|
|
$this->assertLessThanOrEqual(35, $settings['seven_day']);
|
|
$this->assertSame(50, $settings['yearly']);
|
|
$this->assertGreaterThanOrEqual(100, $settings['one_year']);
|
|
$this->assertLessThanOrEqual(500, $settings['one_year']);
|
|
$this->assertSame($settings, $this->draft([$this->item()], $request)['articles'][0]['like_settings']);
|
|
$this->publish($d)->assertOk();
|
|
$a = DB::table('articles')->first();
|
|
$this->assertSame($settings['seven_day'], (int) $a->i7like);
|
|
$this->assertSame(50, (int) $a->i365like);
|
|
$this->assertSame($settings['one_year'], (int) $a->iYearLikeTarget);
|
|
$this->assertNotNull($a->sYearLikeStartedAt);
|
|
$this->assertSame(0, (int) $a->iLikeCount);
|
|
$start = $a->sYearLikeStartedAt;
|
|
DB::table('articles')->where('id', $a->id)->update(['iLikeCount' => 72, 'iYearLikeAdded' => 25, 'i7like' => 2]);
|
|
$updated = $this->draft([$this->item('example', 1)]);
|
|
$this->assertNull($updated['articles'][0]['like_settings']);
|
|
$this->publish($updated)->assertOk();
|
|
$a = DB::table('articles')->first();
|
|
$this->assertSame(72, (int) $a->iLikeCount);
|
|
$this->assertSame(25, (int) $a->iYearLikeAdded);
|
|
$this->assertSame(2, (int) $a->i7like);
|
|
$this->assertSame($settings['one_year'], (int) $a->iYearLikeTarget);
|
|
$this->assertSame($start, $a->sYearLikeStartedAt);
|
|
$this->getJson('/api/editorial/v1/source-articles/'.$a->id)->assertOk()
|
|
->assertJsonPath('like_growth.one_year_added', 25)->assertJsonPath('like_growth.yearly', 50);
|
|
$this->publish($d)->assertOk();
|
|
$this->assertSame(25, (int) DB::table('articles')->value('iYearLikeAdded'));
|
|
}
|
|
|
|
public function test_explicit_like_settings_zero_and_invalid_values(): void
|
|
{
|
|
foreach ([['yearly' => 366], ['one_year' => -1], ['seven_day' => 1.5], ['unknown' => 100]] as $settings) {
|
|
$bad = $this->item();
|
|
$bad['like_settings'] = $settings;
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$bad]])->assertUnprocessable();
|
|
}
|
|
$item = $this->item();
|
|
$item['like_settings'] = ['seven_day' => 0, 'yearly' => 0, 'one_year' => 0];
|
|
$d = $this->draft([$item]);
|
|
$this->publish($d)->assertOk();
|
|
$a = DB::table('articles')->first();
|
|
$this->assertSame(0, (int) $a->iYearLikeTarget);
|
|
$this->assertSame(0, (int) $a->i7like);
|
|
$this->assertSame(0, (int) $a->i365like);
|
|
$this->assertNull($a->sYearLikeStartedAt);
|
|
$item['expected_revision'] = 1;
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$item]])->assertUnprocessable();
|
|
}
|
|
|
|
public function test_existing_tutorial_flowchart_small_labels_are_supported(): void
|
|
{
|
|
$item = $this->item();
|
|
$item['body'] = '<figure class="guide-diagram"><div class="guide-flow"><div><b>导入订阅</b><small>本站免费节点</small></div></div></figure>';
|
|
$d = $this->draft([$item]);
|
|
$this->get($d['articles'][0]['preview_url'])->assertOk()->assertSee('<small>本站免费节点</small>', false);
|
|
}
|
|
|
|
public function test_authentication_and_environment_binding_and_cache_bypass(): void
|
|
{
|
|
$this->withHeaders(['Authorization' => 'Bearer invalid'])->getJson('/api/editorial/v1/catalog')->assertUnauthorized();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token]);
|
|
$this->getJson('/api/editorial/v1/catalog')->assertOk()->assertHeader('Cache-Control', 'no-store, private');
|
|
config(['responsecache.enabled' => true]);
|
|
$this->assertFalse(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/api/editorial/v1/catalog')));
|
|
$this->assertTrue(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/windows')));
|
|
file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'production', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]]));
|
|
$this->getJson('/api/editorial/v1/catalog')->assertStatus(503);
|
|
}
|
|
|
|
public function test_draft_is_private_and_idempotent_with_signed_real_template_preview(): void
|
|
{
|
|
$id = (string) Str::uuid();
|
|
$draft = $this->draft([$this->item()], $id);
|
|
$this->assertSame(0, DB::table('articles')->count());
|
|
$again = $this->draft([$this->item()], $id);
|
|
$this->assertSame($draft['hash'], $again['hash']);
|
|
$this->assertSame(1, DB::table('editorial_documents')->value('revision'));
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => $id, 'articles' => [$this->item('different')]])->assertConflict();
|
|
$preview = $this->get($draft['articles'][0]['preview_url'])->assertOk()->assertSee('草稿预览')->assertSee('教程标题')->assertDontSee('umami.gemcook.net');
|
|
$preview->assertHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
$this->get('/api/editorial/v1/preview/'.$id.'/example')->assertForbidden();
|
|
$this->travel(31)->minutes();
|
|
$this->get($draft['articles'][0]['preview_url'])->assertForbidden();
|
|
$this->travelBack();
|
|
}
|
|
|
|
public function test_publish_is_atomic_and_replay_does_not_create_duplicates(): void
|
|
{
|
|
$a = $this->item('first');
|
|
$a['body'] .= "\n\n[下一步](/a/second.html#install)";
|
|
$d = $this->draft([$a, $this->item('second')]);
|
|
$id = (string) Str::uuid();
|
|
$r = $this->publish($d, $id)->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('effects.search.status', 'done');
|
|
$this->assertSame(2, DB::table('articles')->count());
|
|
$this->assertSame(2, DB::table('article_tag')->count());
|
|
$this->publish($d, $id)->assertOk()->assertJsonPath('publication_id', $id);
|
|
$this->publish($d)->assertOk()->assertJsonPath('publication_id', $id);
|
|
$this->assertSame(2, DB::table('articles')->count());
|
|
$this->assertSame(1, DB::table('editorial_publications')->count());
|
|
$this->getJson('/api/editorial/v1/publications/'.$id)->assertOk()->assertJsonPath('status', 'published');
|
|
$this->getJson('/api/editorial/v1/articles/first')->assertOk()->assertJsonPath('published_revision', 1);
|
|
}
|
|
|
|
public function test_wrong_hash_stale_drafts_and_expected_revision_conflicts_are_rejected(): void
|
|
{
|
|
$d1 = $this->draft([$this->item()]);
|
|
$this->postJson('/api/editorial/v1/drafts/'.$d1['draft_id'].'/publish', ['request_id' => (string) Str::uuid(), 'expected_hash' => str_repeat('0', 64)])->assertConflict();
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$this->item()]])->assertConflict();
|
|
$d2 = $this->draft([$this->item('example', 1)]);
|
|
$this->publish($d1)->assertConflict();
|
|
$this->publish($d2)->assertOk();
|
|
$this->assertSame(2, DB::table('editorial_documents')->value('published_revision'));
|
|
}
|
|
|
|
public function test_invalid_links_anchors_categories_and_html_roll_back_entire_batch(): void
|
|
{
|
|
foreach ([
|
|
['body', '[失效](/a/missing.html)'], ['body', '[失效](#absent)'],
|
|
['body', '[错误分类](/wrong/windows)'],
|
|
['body', '<img src="https://example.com/a.png">'], ['body', '<a href="https://example.com" onclick="alert(1)">x</a>'],
|
|
['category_slug', 'freenode'], ['tag_ids', [999]],
|
|
] as [$field, $value]) {
|
|
$bad = $this->item('bad');
|
|
$bad[$field] = $value;
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$this->item('good'), $bad]])->assertUnprocessable();
|
|
$this->assertSame(0, DB::table('editorial_documents')->count());
|
|
$this->assertSame(0, DB::table('editorial_drafts')->count());
|
|
}
|
|
}
|
|
|
|
public function test_explicit_adoption_preserves_identity_counters_and_original_revision(): void
|
|
{
|
|
$id = DB::table('articles')->insertGetId(['sSlug' => 'existing', 'sTitle' => '旧文', 'sTitleSub' => '旧摘要', 'sContent' => '<p>旧正文</p>', 'iCategoryId' => 3, 'iLikeCount' => 19, 'iViews' => 30, 'created_at' => '2020-01-01 00:00:00']);
|
|
$item = $this->item('existing');
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$item]])->assertConflict();
|
|
$item['adopt_article_id'] = $id;
|
|
$d = $this->draft([$item]);
|
|
$this->assertSame('旧文', DB::table('articles')->value('sTitle'));
|
|
$this->publish($d)->assertOk();
|
|
$this->assertSame($id, DB::table('editorial_documents')->value('article_id'));
|
|
$this->assertSame(19, DB::table('articles')->value('iLikeCount'));
|
|
$this->assertSame('2020-01-01 00:00:00', DB::table('articles')->value('created_at'));
|
|
$this->assertSame(2, DB::table('editorial_versions')->count());
|
|
$this->getJson('/api/editorial/v1/articles/existing/versions/0')->assertOk()->assertJsonPath('content.title', '旧文');
|
|
}
|
|
|
|
public function test_external_edit_and_removed_target_prevent_publish_without_partial_changes(): void
|
|
{
|
|
$first = $this->draft([$this->item()]);
|
|
$this->publish($first)->assertOk();
|
|
$d = $this->draft([$this->item('example', 1), $this->item('another')]);
|
|
DB::table('articles')->where('sSlug', 'example')->update(['sTitle' => '并行修改']);
|
|
$this->publish($d)->assertConflict();
|
|
$this->assertSame(1, DB::table('articles')->count());
|
|
$this->assertSame('并行修改', DB::table('articles')->value('sTitle'));
|
|
}
|
|
|
|
public function test_existing_incoming_anchor_cannot_be_broken(): void
|
|
{
|
|
$d = $this->draft([$this->item('target')]);
|
|
$this->publish($d)->assertOk();
|
|
DB::table('articles')->insert(['sSlug' => 'legacy', 'sTitle' => '旧入口', 'sContent' => '<a href="/a/target.html#install">安装</a>', 'iCategoryId' => 3]);
|
|
$changed = $this->item('target', 1);
|
|
$changed['body'] = '<p>删除原锚点</p>';
|
|
$next = $this->draft([$changed]);
|
|
$this->publish($next)->assertUnprocessable();
|
|
$this->assertStringContainsString('id="install"', DB::table('articles')->where('sSlug', 'target')->value('sContent'));
|
|
}
|
|
|
|
public function test_rollback_creates_new_reviewable_revision_and_preserves_url(): void
|
|
{
|
|
$one = $this->draft([$this->item()]);
|
|
$this->publish($one)->assertOk();
|
|
$changed = $this->item('example', 1);
|
|
$changed['title'] = '第二版';
|
|
$two = $this->draft([$changed]);
|
|
$this->publish($two)->assertOk();
|
|
$request = ['request_id' => (string) Str::uuid(), 'target_revision' => 1, 'expected_revision' => 2];
|
|
$rollback = $this->postJson('/api/editorial/v1/articles/example/rollback', $request)->assertCreated()->json();
|
|
$this->postJson('/api/editorial/v1/articles/example/rollback', $request)->assertCreated();
|
|
$this->assertSame('第二版', DB::table('articles')->value('sTitle'));
|
|
$this->publish($rollback)->assertOk()->assertJsonPath('articles.0.revision', 3);
|
|
$this->assertSame('教程标题', DB::table('articles')->value('sTitle'));
|
|
$this->assertSame('example', DB::table('articles')->value('sSlug'));
|
|
}
|
|
|
|
public function test_uploaded_images_stay_private_until_publication_and_code_newlines_survive(): void
|
|
{
|
|
// Valid raster fixture, no external fetch or image toolkit required.
|
|
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQ0AAAAASUVORK5CYII=');
|
|
$file = UploadedFile::fake()->createWithContent('pixel.png', $bytes);
|
|
$asset = $this->post('/api/editorial/v1/assets', ['image' => $file])->assertCreated()->json();
|
|
$this->uploaded[] = $asset['sha256'];
|
|
$this->assertFileDoesNotExist(public_path('images/editorial/'.$asset['sha256'].'.png'));
|
|
$item = $this->item();
|
|
$item['body'] .= "\n\n\n\n```yaml\none: 1\ntwo: 2\n```";
|
|
$d = $this->draft([$item]);
|
|
$this->get($d['articles'][0]['preview_url'])->assertOk()->assertSee('preview-assets');
|
|
$this->publish($d)->assertOk();
|
|
$this->assertFileExists(public_path('images/editorial/'.$asset['sha256'].'.png'));
|
|
$body = DB::table('articles')->value('sContent');
|
|
$this->assertStringContainsString(' ', $body);
|
|
$this->assertStringNotContainsString("\n", $body);
|
|
$this->assertStringContainsString('/images/editorial/', $body);
|
|
}
|
|
|
|
public function test_search_failure_returns_published_receipt_and_can_be_retried(): void
|
|
{
|
|
config(['scout.driver' => 'unsupported']);
|
|
$d = $this->draft([$this->item()]);
|
|
$r = $this->publish($d)->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('effects.search.status', 'pending')->json();
|
|
config(['scout.driver' => 'collection']);
|
|
$this->postJson('/api/editorial/v1/publications/'.$r['publication_id'].'/synchronize')->assertOk()->assertJsonPath('effects.search.status', 'done');
|
|
$this->assertSame(1, DB::table('articles')->count());
|
|
}
|
|
|
|
public function test_preview_privacy_even_when_response_cache_is_enabled(): void
|
|
{
|
|
config(['responsecache.enabled' => true]);
|
|
$d = $this->draft([$this->item()]);
|
|
$this->get($d['articles'][0]['preview_url'])->assertOk();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])->getJson('/api/editorial/v1/catalog')->assertOk();
|
|
$this->withHeaders(['Authorization' => 'Bearer invalid'])->getJson('/api/editorial/v1/catalog')->assertUnauthorized();
|
|
config(['editorial.enabled' => false]);
|
|
$this->get($d['articles'][0]['preview_url'])->assertStatus(503);
|
|
}
|
|
|
|
public function test_target_removed_after_preview_blocks_entire_publication(): void
|
|
{
|
|
$targetId = DB::table('articles')->insertGetId(['sSlug' => 'target', 'sTitle' => '引用目标', 'sContent' => '<h2 id="step">操作</h2>', 'iCategoryId' => 3]);
|
|
$first = $this->item('first');
|
|
$first['body'] .= "\n\n[步骤](/a/target.html#step)";
|
|
$d = $this->draft([$first, $this->item('second')]);
|
|
DB::table('articles')->where('id', $targetId)->delete();
|
|
$this->publish($d)->assertUnprocessable();
|
|
$this->assertSame(0, DB::table('articles')->count());
|
|
$this->assertSame(0, DB::table('editorial_publications')->count());
|
|
}
|
|
|
|
public function test_unmanaged_article_can_be_read_before_explicit_adoption_and_cover_can_be_cleared(): void
|
|
{
|
|
$id = DB::table('articles')->insertGetId(['sSlug' => 'existing', 'sTitle' => '旧文', 'sTitleSub' => '旧摘要', 'sContent' => '<p>正文</p>', 'sThumb' => 'https://example.com/old.png', 'bPageThumb' => true, 'iCategoryId' => 3]);
|
|
$this->getJson('/api/editorial/v1/source-articles/'.$id)->assertOk()->assertJsonPath('show_cover', true)->assertJsonPath('body_html', '<p>正文</p>');
|
|
$item = $this->item('existing');
|
|
$item['adopt_article_id'] = $id;
|
|
$item['cover'] = null;
|
|
$item['show_cover'] = false;
|
|
$d = $this->draft([$item]);
|
|
$this->publish($d)->assertOk();
|
|
$this->assertNull(DB::table('articles')->value('sThumb'));
|
|
$this->assertSame(0, DB::table('articles')->value('bPageThumb'));
|
|
$this->getJson('/api/editorial/v1/articles/existing/versions/0')->assertJsonPath('content.show_cover', true);
|
|
$resourceId = DB::table('articles')->insertGetId(['sSlug' => 'resource', 'sTitle' => '自动资源', 'sContent' => '<p>resource</p>', 'iCategoryId' => 1]);
|
|
$resource = $this->item('resource');
|
|
$resource['adopt_article_id'] = $resourceId;
|
|
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$resource]])->assertUnprocessable();
|
|
$this->getJson('/api/editorial/v1/source-articles/'.$resourceId)->assertUnprocessable();
|
|
}
|
|
|
|
public function test_raw_script_markup_is_escaped_and_publish_request_id_cannot_be_rebound(): void
|
|
{
|
|
$item = $this->item();
|
|
$item['body'] .= "\n\n<script>alert(1)</script>";
|
|
$d = $this->draft([$item]);
|
|
$this->get($d['articles'][0]['preview_url'])->assertOk()->assertDontSee('<script>alert(1)</script>', false);
|
|
$request = (string) Str::uuid();
|
|
$this->publish($d, $request)->assertOk();
|
|
$next = $this->draft([$this->item('other')]);
|
|
$this->publish($next, $request)->assertConflict();
|
|
$this->assertSame(1, DB::table('articles')->count());
|
|
}
|
|
}
|