[MTM] Update existing draft articles instead of erroring on a repeat Micropub post #139

Merged
jonny merged 1 commit from develop into main 2026-09-19 18:21:18 +02:00
4 changed files with 92 additions and 2 deletions
Showing only changes of commit eb35a0aa2d - Show all commits

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 <noreply@anthropic.com>
Jonny Barnes 2026-09-19 17:16:11 +01:00
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8

View file

@ -40,6 +40,7 @@ class Article extends Model
return [ return [
'titleurl' => [ 'titleurl' => [
'source' => 'title', 'source' => 'title',
'includeTrashed' => true,
], ],
]; ];
} }

View file

@ -8,12 +8,29 @@ use App\Models\Article;
class ArticleService class ArticleService
{ {
/**
* @throws \InvalidArgumentException if a published article already has this title
*/
public function create(array $data): Article public function create(array $data): Article
{ {
return Article::create([ $attributes = [
'title' => $data['name'], 'title' => $data['name'],
'main' => $data['content'], 'main' => $data['content'],
'published' => ($data['post-status'] ?? null) !== 'draft', '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);
} }
} }

View file

@ -8,6 +8,7 @@ use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon; use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Article;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use App\Models\Place; use App\Models\Place;
@ -911,4 +912,64 @@ class MicropubControllerTest extends TestCase
'published' => false, '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());
}
} }

View file

@ -74,6 +74,17 @@ class ArticlesTest extends TestCase
$this->assertEquals(config('app.url').$article->link, $article->uri); $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] #[Test]
public function date_scope_returns_expected_articles(): void public function date_scope_returns_expected_articles(): void
{ {