name('legacy-service.')
->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class])
->group(function (): void {
- Route::get('pressreleases', [PressReleaseController::class, 'index'])->name('pressreleases.index');
- Route::post('pressreleases', [PressReleaseController::class, 'store'])->name('pressreleases.store');
- Route::get('pressreleases/{pressRelease}', [PressReleaseController::class, 'show'])->name('pressreleases.show');
- Route::get('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'index'])->name('pressreleases.images.index');
- Route::post('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'store'])->name('pressreleases.images.store');
+ // Paritäts-Controller (10.07.2026, dev/release/api-legacy-paritaet.md):
+ // die 5 real genutzten Alt-Calls (connektar-Flow) verhaltensgleich —
+ // verschachtelte Alt-Params, Alt-Responses, Anlegen = Einreichen.
+ Route::post('pressreleases', [LegacyPressReleaseController::class, 'store'])->name('pressreleases.store');
+ Route::get('pressreleases/{pressRelease}', [LegacyPressReleaseController::class, 'show'])->name('pressreleases.show');
+ Route::post('pressreleases/{pressRelease}/images', [LegacyPressReleaseImageController::class, 'store'])->name('pressreleases.images.store');
+ Route::post('users/me/companies', [LegacyCompanyController::class, 'store'])->name('companies.store');
+ Route::post('users/me/companies/{company}/contacts', [LegacyContactController::class, 'store'])->name('contacts.store');
+ // Real ungenutzte, aber harmlose Lese-Aliasse (v1-Format).
+ Route::get('pressreleases', [PressReleaseController::class, 'index'])->name('pressreleases.index');
+ Route::get('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'index'])->name('pressreleases.images.index');
Route::get('users/me/companies', [CompanyController::class, 'index'])->name('companies.index');
Route::get('users/me/companies/{company}', [CompanyController::class, 'show'])->name('companies.show');
});
diff --git a/tests/Feature/Api/LegacyApiCompatibilityTest.php b/tests/Feature/Api/LegacyApiCompatibilityTest.php
index e97060a6..44ee235d 100644
--- a/tests/Feature/Api/LegacyApiCompatibilityTest.php
+++ b/tests/Feature/Api/LegacyApiCompatibilityTest.php
@@ -10,6 +10,7 @@ use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\PersonalAccessToken;
use Tests\TestCase;
@@ -125,7 +126,9 @@ test('the legacy service paths require authentication', function () {
test('retired legacy service endpoints answer with a helpful 410 instead of 404', function () {
/** @var TestCase $this */
- $this->postJson('/service/users/me/companies', ['company' => ['name' => 'Test']])
+ // Company-Create ist seit 10.07. ein echter Paritaets-Endpoint (S1);
+ // als weiterhin abgeschalteter Alt-Pfad dient das Einzelbild-GET.
+ $this->getJson('/service/pressreleases/1/images/1')
->assertStatus(410)
->assertJsonStructure(['message', 'account_url', 'docs_url']);
@@ -147,21 +150,26 @@ test('legacy service calls default the ai declaration instead of breaking old cl
// Store ohne KI-Felder (Alt-Client): Default "ohne generative KI erstellt"
// (Entscheidung 10.07.2026 — das API-Kunden-Mailing weist darauf hin).
+ Queue::fake();
$response = $this->postJson('/service/pressreleases', [
- 'company_id' => $company->id,
- 'category_id' => $category->id,
- 'language' => 'de',
- 'title' => 'Alt-Client-Meldung',
- 'text' => 'Inhalt aus einer bestehenden Integration.',
+ 'press_release' => [
+ 'company_id' => $company->id,
+ 'category_id' => $category->id,
+ 'language' => 'de',
+ 'title' => 'Alt-Client-Meldung',
+ 'text' => 'Inhalt aus einer bestehenden Integration.',
+ ],
], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated();
- $pressRelease = PressRelease::withoutGlobalScopes()->find($response->json('data.id'));
+ $pressRelease = PressRelease::withoutGlobalScopes()->find((int) basename((string) $response->headers->get('Location')));
expect($pressRelease->ai_text_declaration?->value)->toBe('none')
->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull();
// Bild-Upload ohne KI-Feld: Default Originalfoto.
$this->postJson("/service/pressreleases/{$pressRelease->id}/images", [
- 'image' => UploadedFile::fake()->image('alt-client.jpg'),
+ 'press_release_image' => [
+ 'image' => UploadedFile::fake()->image('alt-client.jpg'),
+ ],
], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated();
expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original');
diff --git a/tests/Feature/Api/LegacyServiceParityTest.php b/tests/Feature/Api/LegacyServiceParityTest.php
new file mode 100644
index 00000000..da2a5bf2
--- /dev/null
+++ b/tests/Feature/Api/LegacyServiceParityTest.php
@@ -0,0 +1,222 @@
+seed(RolesAndPermissionsSeeder::class);
+ config()->set('billing.enforce_booking', false);
+
+ $user = User::factory()->create([
+ 'is_active' => true,
+ 'portal' => Portal::Businessportal24,
+ ]);
+ $user->forceFill(['registration_type' => 'apiuser'])->save();
+
+ if ($immediatePublication) {
+ $user->givePermissionTo('api:immediate-publication');
+ }
+
+ Sanctum::actingAs($user);
+
+ return $user;
+}
+
+test('the full connektar flow works like the old api with immediate publishing', function () {
+ /** @var TestCase $this */
+ Queue::fake();
+ Storage::fake('public');
+ $user = parityApiUser();
+
+ // 1. Firma anlegen — verschachtelte Alt-Params, 201 + Location + {}.
+ $response = $this->postJson('/service/users/me/companies', [
+ 'company' => [
+ 'name' => 'Muster Energie GmbH',
+ 'address' => "Musterstraße 1\n33602 Bielefeld",
+ 'country_id' => 177,
+ 'email' => 'info@muster-energie.de',
+ 'website' => 'https://muster-energie.de',
+ ],
+ ])->assertCreated();
+
+ expect($response->headers->get('Location'))->toContain('/service/users/me/companies/')
+ ->and($response->json())->toBe([]);
+
+ $company = Company::withoutGlobalScopes()->latest('id')->first();
+ expect($company->country_code)->toBe('DE')
+ ->and($company->is_active)->toBeTrue()
+ ->and($company->owner_user_id)->toBe($user->id);
+
+ // 2. Pressekontakt anlegen — salutation_id 1 → mr.
+ $this->postJson("/service/users/me/companies/{$company->id}/contacts", [
+ 'contact' => [
+ 'salutation_id' => 1,
+ 'first_name' => 'Max',
+ 'last_name' => 'Mustermann',
+ 'email' => 'presse@muster-energie.de',
+ ],
+ ])->assertCreated();
+
+ $contact = $company->contacts()->first();
+ expect($contact->salutation_key)->toBe('mr');
+
+ // 3. PM anlegen — Anlegen IST Einreichen; mit Sofort-Publish-Recht
+ // und Schalter aus geht sie sofort live (Alt-Verhalten, Log-Beleg).
+ $category = Category::factory()->create(['portal' => Portal::Businessportal24, 'legacy_id' => 4242]);
+
+ $response = $this->postJson('/service/pressreleases', [
+ 'press_release' => [
+ 'company_id' => $company->id,
+ 'category_id' => 4242,
+ 'language' => 'de',
+ 'title' => 'Muster Energie eröffnet neuen Standort',
+ 'text' => 'Vollkommen unauffälliger Inhalt aus einer Alt-Integration.',
+ 'contacts_list' => [$contact->id],
+ ],
+ ])->assertCreated();
+
+ expect($response->json())->toBe([]);
+
+ $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->first();
+ expect($pressRelease->status)->toBe(PressReleaseStatus::Published)
+ ->and($pressRelease->published_at)->not->toBeNull()
+ ->and($pressRelease->category_id)->toBe($category->id)
+ ->and($pressRelease->ai_text_declaration?->value)->toBe('none')
+ ->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull();
+
+ // 4. Bild hochladen — verschachtelt, KI-Default Originalfoto.
+ $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [
+ 'press_release_image' => [
+ 'image' => UploadedFile::fake()->image('pressefoto.jpg', 1200, 800),
+ 'copyright' => 'Muster Energie GmbH',
+ ],
+ ])->assertCreated();
+
+ expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original');
+
+ // 5. GET als Veröffentlichungs-Kontrolle: flaches Alt-Objekt, Hits zählen.
+ $this->getJson("/service/pressreleases/{$pressRelease->id}")
+ ->assertOk()
+ ->assertJsonStructure(['id', 'title', 'text', 'teaser_end', 'language', 'category_id', 'view_url', 'company' => ['viewUrl', 'name']])
+ ->assertJsonPath('company.name', 'Muster Energie GmbH');
+
+ expect(PressRelease::withoutGlobalScopes()->find($pressRelease->id)->hits)->toBe(1);
+});
+
+test('the admin switch routes legacy submissions through the ai review instead', function () {
+ /** @var TestCase $this */
+ Queue::fake();
+ $user = parityApiUser();
+ AppSetting::setValue(LegacyPressReleaseController::SETTING_AI_REVIEW, true);
+
+ $company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
+ $user->companies()->attach($company->id, ['role' => 'owner']);
+ $category = Category::factory()->create(['portal' => Portal::Businessportal24]);
+
+ $this->postJson('/service/pressreleases', [
+ 'press_release' => [
+ 'company_id' => $company->id,
+ 'category_id' => $category->id,
+ 'title' => 'Wird erst KI-geprüft',
+ 'text' => 'Inhalt, der durch die KI-Prüfung läuft.',
+ ],
+ ])->assertCreated();
+
+ $pressRelease = PressRelease::withoutGlobalScopes()->latest('id')->first();
+ expect($pressRelease->status)->toBe(PressReleaseStatus::Review);
+ Queue::assertPushed(ClassifyPressRelease::class);
+
+ // Polling-Kontrolle des Alt-Clients: unveröffentlicht = 404.
+ $this->getJson("/service/pressreleases/{$pressRelease->id}")->assertNotFound();
+});
+
+test('without the immediate publication permission legacy posts stay in review', function () {
+ /** @var TestCase $this */
+ Queue::fake();
+ $user = parityApiUser(immediatePublication: false);
+
+ $company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
+ $user->companies()->attach($company->id, ['role' => 'owner']);
+ $category = Category::factory()->create(['portal' => Portal::Businessportal24]);
+
+ $this->postJson('/service/pressreleases', [
+ 'press_release' => [
+ 'company_id' => $company->id,
+ 'category_id' => $category->id,
+ 'title' => 'Ohne Sonderrecht',
+ 'text' => 'Inhalt ohne Sofort-Publish-Privileg.',
+ ],
+ ])->assertCreated();
+
+ expect(PressRelease::withoutGlobalScopes()->latest('id')->first()->status)->toBe(PressReleaseStatus::Review);
+});
+
+test('legacy errors use the old global and field error format', function () {
+ /** @var TestCase $this */
+ parityApiUser();
+
+ $this->postJson('/service/pressreleases', [
+ 'press_release' => ['title' => 'Ohne Pflichtfelder'],
+ ])->assertUnprocessable()
+ ->assertJsonStructure(['global_errors', 'field_errors']);
+
+ // Fremde Firma → Alt-Wortlaut als global_error.
+ $foreign = Company::factory()->create(['portal' => Portal::Businessportal24]);
+ $this->postJson("/service/users/me/companies/{$foreign->id}/contacts", [
+ 'contact' => ['first_name' => 'Max', 'last_name' => 'Muster'],
+ ])->assertForbidden()
+ ->assertJsonPath('global_errors.0', 'You may only access companies which are related to your user-account');
+});
+
+test('api submissions notify the press contact instead of the api submitter', function () {
+ /** @var TestCase $this */
+ Mail::fake();
+ Queue::fake();
+ $user = parityApiUser();
+
+ $company = Company::factory()->create([
+ 'portal' => Portal::Businessportal24,
+ 'is_active' => true,
+ 'email' => 'firma@muster.de',
+ ]);
+ $user->companies()->attach($company->id, ['role' => 'owner']);
+ $category = Category::factory()->create(['portal' => Portal::Businessportal24]);
+ $contact = $company->contacts()->create([
+ 'portal' => Portal::Businessportal24->value,
+ 'first_name' => 'Max',
+ 'last_name' => 'Mustermann',
+ 'email' => 'presse@muster.de',
+ ]);
+
+ $this->postJson('/service/pressreleases', [
+ 'press_release' => [
+ 'company_id' => $company->id,
+ 'category_id' => $category->id,
+ 'title' => 'Mail geht an den Pressekontakt',
+ 'text' => 'Inhalt für den Mail-Routing-Test.',
+ 'contacts_list' => [$contact->id],
+ ],
+ ])->assertCreated();
+
+ Mail::assertQueued(PressReleasePublished::class, fn ($mail) => $mail->hasTo('presse@muster.de'));
+});