From eb35a0aa2d4fef3b84e8653f9d1a0e7bf8dbcde1 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 17:16:11 +0100 Subject: [PATCH] Update existing draft articles instead of erroring on a repeat Micropub post If a Micropub h-entry post's title matches an existing article that's still a draft, update that article in place rather than trying to insert a duplicate. If it matches one that's already published, reject the request with a clear error instead of silently colliding. Also set includeTrashed on Article's slug config as a safety net: this model soft-deletes, and Sluggable's uniqueness check ignores trashed rows by default, so a previously-deleted article's title could crash new inserts with a raw unique constraint violation (this is exactly what surfaced in Flare as a UniqueConstraintViolationException on articles_titleurl_unique once the prior swallowed-exception fix shipped). Co-Authored-By: Claude Sonnet 5 --- app/Models/Article.php | 1 + app/Services/ArticleService.php | 21 +++++++- tests/Feature/MicropubControllerTest.php | 61 ++++++++++++++++++++++++ tests/Unit/ArticlesTest.php | 11 +++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index 9ac2335d..330ff03e 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -40,6 +40,7 @@ class Article extends Model return [ 'titleurl' => [ 'source' => 'title', + 'includeTrashed' => true, ], ]; } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 2372ffb7..ab91f9e4 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -8,12 +8,29 @@ use App\Models\Article; class ArticleService { + /** + * @throws \InvalidArgumentException if a published article already has this title + */ public function create(array $data): Article { - return Article::create([ + $attributes = [ 'title' => $data['name'], 'main' => $data['content'], 'published' => ($data['post-status'] ?? null) !== 'draft', - ]); + ]; + + $existing = Article::where('title', $data['name'])->first(); + + if ($existing !== null) { + if ($existing->published) { + throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published"); + } + + $existing->update($attributes); + + return $existing; + } + + return Article::create($attributes); } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 86ffa972..3fd8b515 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -8,6 +8,7 @@ use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; +use App\Models\Article; use App\Models\Media; use App\Models\Note; use App\Models\Place; @@ -911,4 +912,64 @@ class MicropubControllerTest extends TestCase 'published' => false, ]); } + + #[Test] + public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void + { + $draft = Article::create([ + 'title' => 'WireGuard', + 'main' => 'Early draft content', + 'published' => false, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Finished content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['response' => 'created']) + ->assertStatus(201); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + $this->assertDatabaseHas('articles', [ + 'id' => $draft->id, + 'title' => 'WireGuard', + 'main' => 'Finished content', + 'published' => true, + ]); + } + + #[Test] + public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void + { + Article::create([ + 'title' => 'WireGuard', + 'main' => 'Published content', + 'published' => true, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Some other content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + } } diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index 0de3277d..afcc0828 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -74,6 +74,17 @@ class ArticlesTest extends TestCase $this->assertEquals(config('app.url').$article->link, $article->uri); } + #[Test] + public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void + { + $original = Article::create(['title' => 'My Title', 'main' => 'Content']); + $original->delete(); + + $newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']); + + $this->assertEquals('my-title-2', $newArticle->titleurl); + } + #[Test] public function date_scope_returns_expected_articles(): void {