From 85d7c2b0d58204f683989938b5ef3af49d40af74 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 18 Sep 2026 13:35:42 +0000 Subject: [PATCH] =?UTF-8?q?betrieb:=20Logs=20je=20Thema=20in=20Tagesdateie?= =?UTF-8?q?n=20(billing,=20ki,=20monitoring,=20market);=20Doku:=20Duplicat?= =?UTF-8?q?e-Content-Decision=20durch=20Anhang=202=20abgel=C3=B6st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config/logging.php: Themen-Channels billing, ki, monitoring, market als daily-Dateien mit gemeinsamer Aufbewahrung (LOG_DAILY_DAYS), Stack-Default auf daily; Channel `testing` für die Suite. - Fachliche Log-Aufrufe in Billing, KI-Jobs, OperationalAlert und Kursdaten auf Log::channel() umgestellt. - phpunit.xml + TestCase: Suite schreibt ausschließlich nach testing-YYYY-MM-DD.log; LogChannelsTest sichert die Konfiguration. - docs/weiteres: Decision-Update Duplicate-Content entfernt (überholt durch Roadmap-Anhang 2), Decision-Update Verlinkung & Backlinks als Referenz wieder abgelegt, README-Index nachgezogen. Co-Authored-By: Claude Fable 5.1 --- .env.example | 11 +- app/Http/Controllers/CheckoutController.php | 2 +- .../StripeInvoiceRedirectController.php | 2 +- app/Jobs/ClassifyPressRelease.php | 6 +- app/Jobs/ScorePressRelease.php | 2 +- app/Listeners/ProcessStripeWebhook.php | 26 ++-- app/Services/Billing/ManualInvoiceService.php | 2 +- .../Billing/StripeCheckoutService.php | 8 +- .../Billing/VatIdValidationService.php | 2 +- app/Services/Company/CompanyProfileCheck.php | 2 +- .../PressRelease/CorrectionService.php | 4 +- app/Services/Web/MarketDataService.php | 6 +- app/Support/OperationalAlert.php | 4 +- config/logging.php | 60 +++++++- docs/README.md | 4 +- ... Duplicate-Content & Duplicate-Checking.md | 65 -------- ...nkung & Backlinks in Pressemitteilungen.md | 145 ++++++++++++++++++ phpunit.xml | 1 + tests/Feature/LogChannelsTest.php | 69 +++++++++ tests/Feature/OperationalAlertTest.php | 6 +- tests/TestCase.php | 18 +++ 21 files changed, 338 insertions(+), 107 deletions(-) delete mode 100644 docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md create mode 100644 docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md create mode 100644 tests/Feature/LogChannelsTest.php diff --git a/.env.example b/.env.example index e2a10a49..60eb00a1 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,12 @@ PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 LOG_CHANNEL=stack -# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt. -# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook). -LOG_STACK=single +# `daily`: eine Datei je Tag (laravel-YYYY-MM-DD.log), Rotation nach +# LOG_DAILY_DAYS. Fachliche Meldungen liegen daneben in eigenen Tagesdateien +# je Thema (billing-, ki-, monitoring-, market-, admin-slow-*.log) — +# siehe config/logging.php. `single` nur noch als Notnagel, wächst unbegrenzt. +LOG_STACK=daily +LOG_DAILY_DAYS=14 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug @@ -255,7 +258,7 @@ CRAWLER_HITS_RECORDED_SINCE=2026-09-18 GSC_ENABLED=false GSC_SERVICE_ACCOUNT_FILE= GSC_PROPERTY_PRESSEECHO=sc-domain:presseecho.de -GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.de +GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.com GSC_DAILY_BUDGET=300 GSC_CUSTOMER_VISIBLE=false diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index bc8bfc45..21f464c5 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -114,7 +114,7 @@ class CheckoutController extends Controller 'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']), ]); } catch (\Throwable $exception) { - Log::error('Tarifwechsel fehlgeschlagen.', [ + Log::channel('billing')->error('Tarifwechsel fehlgeschlagen.', [ 'user_id' => $user->id, 'plan' => $plan->slug, 'interval' => $interval, diff --git a/app/Http/Controllers/StripeInvoiceRedirectController.php b/app/Http/Controllers/StripeInvoiceRedirectController.php index 597389f4..accaf01c 100644 --- a/app/Http/Controllers/StripeInvoiceRedirectController.php +++ b/app/Http/Controllers/StripeInvoiceRedirectController.php @@ -24,7 +24,7 @@ class StripeInvoiceRedirectController extends Controller $stripeInvoice = Cashier::stripe()->invoices->retrieve($invoice->stripe_invoice_id); $hostedUrl = $stripeInvoice->hosted_invoice_url; } catch (\Throwable $exception) { - Log::warning('Stripe-Rechnungsansicht nicht erreichbar.', [ + Log::channel('billing')->warning('Stripe-Rechnungsansicht nicht erreichbar.', [ 'invoice_id' => $invoice->id, 'stripe_invoice_id' => $invoice->stripe_invoice_id, 'error' => $exception->getMessage(), diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php index f4b9b604..7cb2e435 100644 --- a/app/Jobs/ClassifyPressRelease.php +++ b/app/Jobs/ClassifyPressRelease.php @@ -165,14 +165,14 @@ class ClassifyPressRelease implements ShouldQueue } catch (StaleClassificationException $exception) { // Inhalt zwischen Audit und Publish-Sperre geändert: publish() hat // bereits eine Prüfung für den aktuellen Stand eingeplant (U02). - Log::info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [ + Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [ 'press_release_id' => $pressRelease->id, ]); } catch (QuotaExceededException $exception) { // Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM // bleibt in Review, damit Autor/Admin bewusst entscheiden — kein // Job-Retry, der die Klassifikation sinnlos wiederholt. - Log::info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [ + Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [ 'press_release_id' => $pressRelease->id, ]); } @@ -208,7 +208,7 @@ class ClassifyPressRelease implements ShouldQueue try { return $manager->driver($provider)->classify($pressRelease); } catch (\Throwable $exception) { - Log::warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [ + Log::channel('ki')->warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [ 'press_release_id' => $pressRelease->id, 'provider' => $provider, 'error' => $exception->getMessage(), diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php index 79d3a07f..9948f4c0 100644 --- a/app/Jobs/ScorePressRelease.php +++ b/app/Jobs/ScorePressRelease.php @@ -124,7 +124,7 @@ class ScorePressRelease implements ShouldQueue try { return $manager->driver($provider)->score($pressRelease); } catch (\Throwable $exception) { - Log::warning('Content-Score fiel auf den deterministischen Treiber zurück.', [ + Log::channel('ki')->warning('Content-Score fiel auf den deterministischen Treiber zurück.', [ 'press_release_id' => $pressRelease->id, 'provider' => $provider, 'error' => $exception->getMessage(), diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index d09642c8..82ab3cf2 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -192,7 +192,7 @@ class ProcessStripeWebhook ]); if ($cleared > 0) { - Log::info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [ + Log::channel('billing')->info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [ 'stripe_subscription_id' => $stripeId, 'stripe_price' => $price, ]); @@ -217,7 +217,7 @@ class ProcessStripeWebhook $user = Cashier::findBillable($stripeInvoice['customer'] ?? null); if (! $user instanceof User) { - Log::warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [ + Log::channel('billing')->warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [ 'stripe_invoice_id' => $stripeInvoiceId, 'stripe_customer' => $stripeInvoice['customer'] ?? null, ]); @@ -239,7 +239,7 @@ class ProcessStripeWebhook // (QA-Befund 07.07.: unsigned-Spalten → Webhook-500) — der Beleg // selbst bleibt bei Stripe einsehbar. if ($total <= 0) { - Log::info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [ + Log::channel('billing')->info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [ 'stripe_invoice_id' => $stripeInvoiceId, 'total_cents' => $total, ]); @@ -286,7 +286,7 @@ class ProcessStripeWebhook return; } - Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [ + Log::channel('billing')->info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [ 'number' => $invoice->number, 'stripe_invoice_id' => $stripeInvoiceId, ]); @@ -351,7 +351,7 @@ class ProcessStripeWebhook // _failed`. Erfüllt wird deshalb nur bei bestätigter Zahlung; beide // Erfüllungspfade sind idempotent, doppelte Events bleiben folgenlos. if (! in_array($session['payment_status'] ?? null, ['paid', 'no_payment_required'], true)) { - Log::info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [ + Log::channel('billing')->info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [ 'session_id' => $session['id'] ?? null, 'payment_status' => $session['payment_status'] ?? null, ]); @@ -372,7 +372,7 @@ class ProcessStripeWebhook */ private function logFailedAsyncPayment(array $session): void { - Log::warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [ + Log::channel('billing')->warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [ 'session_id' => $session['id'] ?? null, 'single_purchase_id' => $session['metadata']['single_purchase_id'] ?? null, 'credit_topup_id' => $session['metadata']['credit_topup_id'] ?? null, @@ -402,7 +402,7 @@ class ProcessStripeWebhook $session['payment_intent'] ?? null, ); - Log::info('Wallet-Aufladung gutgeschrieben.', ['credit_topup_id' => $topup->id]); + Log::channel('billing')->info('Wallet-Aufladung gutgeschrieben.', ['credit_topup_id' => $topup->id]); } /** @@ -459,7 +459,7 @@ class ProcessStripeWebhook $purchase = $result['purchase']; if ($result['marked_paid']) { - Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); + Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); // Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je // Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade @@ -479,7 +479,7 @@ class ProcessStripeWebhook Mail::to($buyer->email)->queue(new PressReleaseChangeApplied($buyer, $changedRelease, $changeRequest)); } - Log::info('Pressemitteilung nach bezahlter Änderung aktualisiert.', [ + Log::channel('billing')->info('Pressemitteilung nach bezahlter Änderung aktualisiert.', [ 'single_purchase_id' => $purchase->id, 'press_release_id' => $changedRelease->id, ]); @@ -497,7 +497,7 @@ class ProcessStripeWebhook Mail::to($buyer->email)->queue(new PressReleaseDepublished($buyer, $pressRelease)); } - Log::info('Pressemitteilung depubliziert.', [ + Log::channel('billing')->info('Pressemitteilung depubliziert.', [ 'single_purchase_id' => $purchase->id, 'press_release_id' => $pressRelease->id, ]); @@ -522,7 +522,7 @@ class ProcessStripeWebhook ->first(); if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) { - Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [ + Log::channel('billing')->warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [ 'single_purchase_id' => $purchase->id, 'press_release_id' => $purchase->press_release_id, ]); @@ -568,7 +568,7 @@ class ProcessStripeWebhook ->first(); if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) { - Log::warning('Änderung bezahlt, aber PM nicht (mehr) veröffentlicht.', [ + Log::channel('billing')->warning('Änderung bezahlt, aber PM nicht (mehr) veröffentlicht.', [ 'single_purchase_id' => $purchase->id, 'press_release_id' => $purchase->press_release_id, ]); @@ -581,7 +581,7 @@ class ProcessStripeWebhook } catch (CorrectionTooLargeException) { // Kauf bleibt bezahlt/unkonsumiert — Klärung durch den Admin, // statt eine Obergrenzen-Verletzung stillschweigend zu übernehmen. - Log::error('Bezahlte Änderung überschreitet die Obergrenze — nicht ausgeführt.', [ + Log::channel('billing')->error('Bezahlte Änderung überschreitet die Obergrenze — nicht ausgeführt.', [ 'single_purchase_id' => $purchase->id, 'legal_request_id' => $legalRequest->id, ]); diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index 7527ca08..5e3beb87 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -186,7 +186,7 @@ class ManualInvoiceService */ private function hold(UserPaymentOption $option, string $reason, array $context): null { - Log::warning('MAN-Rechnung übersprungen: '.$reason.'.', $context); + Log::channel('billing')->warning('MAN-Rechnung übersprungen: '.$reason.'.', $context); $option->forceFill([ 'billing_hold_reason' => $reason, diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index bc37f199..e356b5ea 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -212,7 +212,7 @@ class StripeCheckoutService try { Cashier::stripe()->subscriptionSchedules->release($subscription->stripe_schedule_id); } catch (InvalidRequestException $exception) { - Log::warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [ + Log::channel('billing')->warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [ 'subscription_id' => $subscription->id, 'stripe_schedule_id' => $subscription->stripe_schedule_id, 'error' => $exception->getMessage(), @@ -241,7 +241,7 @@ class StripeCheckoutService $user->createOrGetStripeCustomer(); $user->syncStripeCustomerDetails(); } catch (\Throwable $exception) { - Log::warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [ + Log::channel('billing')->warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [ 'user_id' => $user->id, 'error' => $exception->getMessage(), ]); @@ -299,7 +299,7 @@ class StripeCheckoutService $user->createTaxId('eu_vat', $vatId); } } catch (\Throwable $exception) { - Log::warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [ + Log::channel('billing')->warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [ 'user_id' => $user->id, 'error' => $exception->getMessage(), ]); @@ -332,7 +332,7 @@ class StripeCheckoutService $check = $this->vatIdValidation->check($vatId, (string) $user->billingAddress?->country_code); if (! $check['status']->isUsable()) { - Log::warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [ + Log::channel('billing')->warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [ 'user_id' => $user->id, 'vat_id' => $vatId, 'status' => $check['status']->value, diff --git a/app/Services/Billing/VatIdValidationService.php b/app/Services/Billing/VatIdValidationService.php index 010061c4..1b04e5f8 100644 --- a/app/Services/Billing/VatIdValidationService.php +++ b/app/Services/Billing/VatIdValidationService.php @@ -117,7 +117,7 @@ class VatIdValidationService 'angefragteUstid' => $vatId, ]); } catch (Throwable $exception) { - Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]); + Log::channel('billing')->warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]); return $this->result( VatIdCheckStatus::Unverified, diff --git a/app/Services/Company/CompanyProfileCheck.php b/app/Services/Company/CompanyProfileCheck.php index e407264d..0b5d320b 100644 --- a/app/Services/Company/CompanyProfileCheck.php +++ b/app/Services/Company/CompanyProfileCheck.php @@ -42,7 +42,7 @@ class CompanyProfileCheck try { return $this->classification->driver()->classify($proposed); } catch (\Throwable $e) { - Log::warning('Profilprüfung (Rot-Ebene) fehlgeschlagen — deterministischer Fallback.', [ + Log::channel('ki')->warning('Profilprüfung (Rot-Ebene) fehlgeschlagen — deterministischer Fallback.', [ 'company_id' => $company->getKey(), 'error' => $e->getMessage(), ]); diff --git a/app/Services/PressRelease/CorrectionService.php b/app/Services/PressRelease/CorrectionService.php index 726648ff..329e6d4b 100644 --- a/app/Services/PressRelease/CorrectionService.php +++ b/app/Services/PressRelease/CorrectionService.php @@ -59,7 +59,7 @@ class CorrectionService try { return $this->classification->driver()->classify($proposed); } catch (\Throwable $e) { - Log::warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [ + Log::channel('ki')->warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [ 'press_release_id' => $pressRelease->id, 'error' => $e->getMessage(), ]); @@ -223,7 +223,7 @@ class CorrectionService } if (! $classification['has_changes']) { - Log::warning('Bezahlte Änderung ohne wirksame Feld-Änderung — nichts zu tun.', [ + Log::channel('billing')->warning('Bezahlte Änderung ohne wirksame Feld-Änderung — nichts zu tun.', [ 'legal_request_id' => $legalRequest->id, 'press_release_id' => $pressRelease->id, ]); diff --git a/app/Services/Web/MarketDataService.php b/app/Services/Web/MarketDataService.php index 72139df6..2372f95c 100644 --- a/app/Services/Web/MarketDataService.php +++ b/app/Services/Web/MarketDataService.php @@ -64,7 +64,7 @@ class MarketDataService } if ($slides === []) { - Log::warning('market:refresh lieferte keine Kursdaten — bestehender Cache-Stand bleibt erhalten.'); + Log::channel('market')->warning('market:refresh lieferte keine Kursdaten — bestehender Cache-Stand bleibt erhalten.'); return $this->slides(); } @@ -94,13 +94,13 @@ class MarketDataService 'interval' => '1d', ]); } catch (Throwable $exception) { - Log::warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'error' => $exception->getMessage()]); + Log::channel('market')->warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'error' => $exception->getMessage()]); return null; } if (! $response->successful()) { - Log::warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'status' => $response->status()]); + Log::channel('market')->warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'status' => $response->status()]); return null; } diff --git a/app/Support/OperationalAlert.php b/app/Support/OperationalAlert.php index 3acc2361..7240782d 100644 --- a/app/Support/OperationalAlert.php +++ b/app/Support/OperationalAlert.php @@ -20,7 +20,7 @@ class OperationalAlert */ public static function send(string $summary, array $context = []): void { - Log::critical('[monitoring] '.$summary, $context); + Log::channel('monitoring')->critical('[monitoring] '.$summary, $context); if (! config('monitoring.alerts.enabled')) { return; @@ -35,7 +35,7 @@ class OperationalAlert try { Mail::to($recipient)->send(new OperationalAlertMail($summary, $context)); } catch (Throwable $e) { - Log::error('[monitoring] Alert-Mail konnte nicht versendet werden.', [ + Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [ 'summary' => $summary, 'error' => $e->getMessage(), ]); diff --git a/config/logging.php b/config/logging.php index 3ba5a9f2..98eded0f 100644 --- a/config/logging.php +++ b/config/logging.php @@ -5,6 +5,19 @@ use Monolog\Handler\StreamHandler; use Monolog\Handler\SyslogUdpHandler; use Monolog\Processor\PsrLogMessageProcessor; +/** + * Themen-Channel: eine Tagesdatei je Thema, Rotation wie `daily`. + * + * @return array{driver: string, path: string, level: mixed, days: mixed, replace_placeholders: bool} + */ +$topicChannel = fn (string $topic): array => [ + 'driver' => 'daily', + 'path' => storage_path("logs/{$topic}.log"), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, +]; + return [ /* @@ -54,7 +67,7 @@ return [ 'stack' => [ 'driver' => 'stack', - 'channels' => explode(',', env('LOG_STACK', 'single')), + 'channels' => explode(',', env('LOG_STACK', 'daily')), 'ignore_exceptions' => false, ], @@ -73,6 +86,36 @@ return [ 'replace_placeholders' => true, ], + /* + |---------------------------------------------------------------------- + | Themen-Channels (je Thema eine Tagesdatei) + |---------------------------------------------------------------------- + | + | Fachliche Meldungen landen nicht im allgemeinen `laravel-*.log`, + | sondern über `Log::channel('…')` in einer eigenen Tagesdatei unter + | storage/logs/-YYYY-MM-DD.log. Rotation wie `daily` + | (LOG_DAILY_DAYS). Das allgemeine Log behält Exceptions, `report()` + | und alles, was keinem Thema zugeordnet ist. + | + | billing Stripe-Webhooks, Checkout, Tarifwechsel, STR-/MAN- + | Rechnungen, USt-ID, Erfüllung bezahlter Vorgänge + | ki KI-Klassifikation, Content-Score, Profil- und + | Vorprüfung, Auto-Veröffentlichung + | monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene + | Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces + | market Kursdaten-Abruf (market:refresh) + | admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte) + | + */ + + 'billing' => $topicChannel('billing'), + + 'ki' => $topicChannel('ki'), + + 'monitoring' => $topicChannel('monitoring'), + + 'market' => $topicChannel('market'), + 'admin_slow' => [ 'driver' => 'daily', 'path' => storage_path('logs/admin-slow.log'), @@ -81,6 +124,21 @@ return [ 'replace_placeholders' => true, ], + /* + | Testsuite: phpunit.xml setzt LOG_CHANNEL=testing, und tests/TestCase.php + | biegt die Themen-Channels ebenfalls hierher um — Tests schreiben damit + | ausschließlich nach testing-YYYY-MM-DD.log und verwässern weder das + | allgemeine noch die Themen-Logs. Kurze Aufbewahrung, reine Diagnose. + */ + + 'testing' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/testing.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 3, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/docs/README.md b/docs/README.md index 8dc7cae3..5f78927c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,10 +41,10 @@ Sie verlinkt die zentralen Dokumente und sortiert sie nach „Was ist der aktuel ### `weiteres/` — Abgestimmte Decision-Updates (nächste Umsetzungsblöcke) - [`Decision-Update Phase-2-Funktionen & Magic-Link-Änderungsprozess.md`](./weiteres/Decision-Update%20Phase-2-Funktionen%20&%20Magic-Link-%C3%84nderungsprozess.md) — Boost + Veröffentlichungsnachweis (Launch), Magic-Link-Zugangs-/Änderungsprozess, Phase-2-Funktionen (Vorab-Prüfung, Prüfzähler, kostenpflichtige Änderungspfade). **Noch nicht umgesetzt.** -- [`Decision-Update Duplicate-Content & Duplicate-Checking.md`](./weiteres/Decision-Update%20Duplicate-Content%20&%20Duplicate-Checking.md) — Cross-Portal-Duplicate-Content (SEO) vs. Duplikat-Erkennung eingereichter PMs. **Noch nicht umgesetzt.** +- Das Decision-Update „Duplicate-Content & Duplicate-Checking" (12.06.2026, „nichts bauen, nur Canonical-Hygiene") ist überholt und entfernt (18.09.2026): Die API reicht dieselbe Meldung auf beiden Portalen ein, deshalb gibt es seit Anhang 2 der Roadmap (`dev/2026-09-01/roadmap-anhang2-2026.md`) Textprüfsumme, kanonische Zuordnung je Kategorie und Quellenauskunft. - [`Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md`](./weiteres/Detailplan%20Umsetzung%20Launch-Slice%20%28Magic-Link%2C%20Compliance%2C%20Bestandsschutz%2C%20Auth%29.md) — konkreter Umsetzungsplan (WS-1 bis WS-8) aus den beiden Decision-Updates + Merkliste; verbindliche Entscheidungen vom 15.06.2026 (Legacy-Posten bleiben, Pflicht-E-Mail-Verifizierung, Socialite freigegeben). **In Umsetzung** (WS-1, WS-6 ohne Google, WS-2 erledigt). - [`Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md`](./weiteres/Sicherheit%20%26%20Deployment-Hinweise%20%28Auth%2C%20Rollen%2C%20Verifizierung%29.md) — **deployment-kritisch:** Reihenfolge der Migrationen (Verifizierungs-Backfill, Legacy-Rollen-Downgrade), der Sicherheits-Fix der Editor-Überberechtigung, Firmen-Scope-Verhaltensänderung und der Magic-Link-Kontaktzugang. Feed für die Live-Deployment-Checkliste (WS-7). -- Das Decision-Update „Verlinkung & Backlinks" ist umgesetzt (systemseitige `rel`-Auszeichnung, `PressReleaseLinkPolicy`, 12.06.2026) und wurde nach der Integration in §4 des Preisstruktur-Decision-Updates entfernt — Umsetzungsdetails im Hub-Flux-PROGRESS-Log. +- [`Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md`](./weiteres/Decision-Update%20Verlinkung%20&%20Backlinks%20in%20Pressemitteilungen.md) — systemseitige `rel`-Auszeichnung (`sponsored nofollow`), kein Verkauf von Dofollow-Backlinks. **Umgesetzt** (`PressReleaseLinkPolicy`, 12.06.2026); Kurzfassung in §4 des Preisstruktur-Decision-Updates. Als Referenz wieder abgelegt (18.09.2026), weil die Transparenzseite „kostenlos veröffentlichen" die Link-Policy erklärt. ### `user-admin/` — User-/Admin-Backend diff --git a/docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md b/docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md deleted file mode 100644 index 1c85339b..00000000 --- a/docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md +++ /dev/null @@ -1,65 +0,0 @@ - -**Version:** Juni 2026 **Datum:** 12.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Klärung, ob das Portal doppelte/ähnliche Pressemitteilungen automatisch prüfen muss. Separates Thema, bewusst aus dem Phase-2/Magic-Link-Dokument herausgelöst. - ---- - -## 1. Kontext - -Beim Thema Cross-Portal-Veröffentlichung kam Duplicate-Content als SEO-Risiko auf. Daraus entstand die Folgefrage, ob das Portal doppelte oder ähnliche Pressemitteilungen automatisch erkennen/prüfen sollte. Wichtig: Hier werden zwei **verschiedene** Probleme vermischt, die getrennt zu betrachten sind. - ---- - -## 2. Die zwei Duplicate-Szenarien - -||Szenario A: System-Duplicate|Szenario B: Kunden-Duplicate| -|---|---|---| -|**Ursache**|Cross-Portal-Feature spiegelt dieselbe PM auf zwei Domains|Kunde legt manuell zweimal dasselbe an (zwei Firmen-Einträge, zwei ähnliche PMs)| -|**Wer trägt den Schaden**|das Portal (eigene Domains konkurrieren)|der Kunde selbst| -|**Häufigkeit**|systematisch (bei jedem Cross-Post)|seltener Randfall| -|**Status**|**gelöst durch Weglassen**|**kein aktiver Check**| - ---- - -## 3. Szenario A – gelöst durch Weglassen - -Das ursprüngliche SEO-Duplicate entstand ausschließlich durch das geplante Cross-Portal-Feature (eine PM, vom System auf presseecho.de **und** businessportal24.com gespiegelt). Da dieses Feature **bewusst nicht gebaut wird** (Portale optisch, inhaltlich und systemisch getrennt – siehe Decision-Update Phase 2/Magic-Link, Abschnitt 6), entsteht dieser Duplicate gar nicht erst. - -**Kein automatischer Spiegel = kein automatischer Duplicate.** Es ist hier nichts zu prüfen. - ---- - -## 4. Szenario B – bewusst kein automatisches Duplicate-Checking - -Ein Kunde, der manuell zweimal dasselbe einstellt und zweimal zahlt, ist ein seltener Randfall. Ein automatischer Erkennungsmechanismus steht in keinem Verhältnis: - -- **KI-Ähnlichkeitsprüfung** über alle PMs = laufende Kosten bei _jeder_ Veröffentlichung, für einen Randfall -- **DB-Vergleich** („gleiche Firma doppelt + ähnlicher Text") klingt simpel, ist es nicht: „ähnlich" sauber zu definieren (Schwellwert? gleicher Titel? gleicher erster Absatz?) ist die klassische Fuzzy-Matching-Falle – viel Tuning, trotzdem Fehlalarme -- **Gleiche Firma zweimal angelegt** ist oft **legitim** (verschiedene Abteilungen, verschiedene Pressekontakte) – kein verlässliches Duplicate-Signal - -**Entscheidender Punkt:** Den SEO-Nachteil eines selbst erzeugten Duplikats trägt der Kunde, nicht das Portal. Die Leistung wurde erbracht (zwei Veröffentlichungen, zweimal bezahlt). Der Kunde muss nicht vor seiner eigenen Entscheidung geschützt werden. - ---- - -## 5. Was stattdessen greift (kostenlos, sofort) - -1. **Score deckt den relevanten Teil ab.** Die Red-Flag-/Content-Prüfung läuft ohnehin bei jeder PM. Plump kopierte Massen-/Müll-PMs fallen dort als Qualitätsproblem auf – das ist der eigentliche Spam-Filter, kein separates Duplicate-System nötig. -2. **Canonical-Tags sauber setzen.** Rein technisch, keine laufenden Kosten. Jede PM erhält ihre eigene kanonische URL – saubere SEO-Hygiene statt aktivem Prüfprozess. -3. **Phase-3-Option, nur falls es real wird.** Sollte sich im Betrieb zeigen, dass Duplikate tatsächlich ein Problem sind (unwahrscheinlich), lässt sich ein billiger **Hash-/Shingle-Vergleich** auf Titel + erste Absätze nachrüsten – algorithmisch, keine KI. Erst bauen, wenn Daten zeigen, dass es vorkommt. - ---- - -## 6. Entscheidung - -- **Kein automatisches Duplicate-Checking zum Launch.** -- Szenario A (System-Duplicate) ist durch den Verzicht auf Cross-Portal erledigt. -- Szenario B (Kunden-Duplicate) wird nicht aktiv geprüft; der Score fängt Spam/Müll ab, Canonical-Tags sorgen für technische Hygiene. -- Hash-/Shingle-Vergleich bleibt als **Phase-3-Option** notiert, nur bei nachgewiesenem Bedarf. - ---- - -## 7. Anti-Zombie-Check - -- ✅ Keine Kosten auf Vorrat für ein Phantomproblem -- ✅ Kunde wird nicht für legitime Mehrfachnutzung (zwei Abteilungen, zwei Kontakte) fälschlich blockiert -- ✅ Spam-Schutz läuft über den ohnehin vorhandenen Score, nicht über ein zweites paralleles System -- ✅ Technische SEO-Hygiene (Canonical) statt aktivem, fehleranfälligem Prüfprozess \ No newline at end of file diff --git a/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md b/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md new file mode 100644 index 00000000..5be5c63a --- /dev/null +++ b/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md @@ -0,0 +1,145 @@ + + +**Version:** Juni 2026 **Datum:** 11.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Behandlung von Links in Presseartikeln, `rel`-Auszeichnung, Linkangabe und Linktyp-Auswahl im Editor, Abgrenzung zum verkauften Dofollow-Backlink. Präzisiert den entsprechenden Punkt im Decision-Update „Preisstruktur & Veröffentlichungs-Flow". + +--- + +## 1. Kontext & Klarstellung + +In der Preis-Abstimmung war der Punkt „verkaufte Dofollow-Backlinks ausgeschlossen" zu pauschal formuliert und konnte als „keine Links" missverstanden werden. Das ist falsch. Links in Presseartikeln gehören selbstverständlich dazu. Die Frage ist nicht _ob_, sondern _wie ausgezeichnet_ und _wie verkauft_. + +Es geht um zwei verschiedene Dinge, die beide „Backlink" heißen: + +1. **Der Link im Presseartikel zur Kundenseite** – normaler, erwarteter Bestandteil jeder PM. Erlaubt, darf hervorgehoben werden. Muss technisch korrekt ausgezeichnet sein. +2. **Den „Dofollow-Backlink für SEO-Wert" als bezahltes Upsell verkaufen** – das ist die Falle und bleibt ausgeschlossen. + +--- + +## 2. Grundregel (Google-konform) + +**Externe Links in Pressemitteilungen werden systemseitig mit `rel="sponsored"` bzw. `rel="nofollow"` ausgezeichnet.** + +Hintergrund: Google behandelt Links in Pressemitteilungen als Kategorie, die nofollow gehört – der Kunde setzt den Link selbst, es ist keine redaktionelle Empfehlung des Portals. Jeder über Bezahlung oder kommerzielle Vereinbarung entstandene Link muss entsprechend qualifiziert sein; eine fehlende Kennzeichnung von Paid-Placements ist selbst der Richtlinienverstoß. Für kombinierte Fälle (bezahlte Platzierung + kein Endorsement) ist `rel="sponsored nofollow"` zulässig. + +**Konsequenz fürs Produkt:** Die `rel`-Auszeichnung ist **nicht kundenseitig wählbar**. Sie wird vom System anhand der Platzierungs-Kategorie automatisch gesetzt. Damit ist die Domain dauerhaft sauber und es gibt keinen Hebel, über den ein Kunde „dofollow" erzwingen könnte. + +--- + +## 3. Wichtig: nofollow ≠ wertlos + +Die alte Logik „nofollow = nutzlos" gilt seit 2019 nicht mehr. Google behandelt `nofollow`/`sponsored`/`ugc` seitdem als **Hinweis**, nicht als absolute Anweisung. Praktischer Wert für den Kunden bleibt: + +- **Referral-Traffic** – direkte Klicks vom Presseartikel auf die Kundenseite +- **Marken-Assoziation & Kontext** – die Nennung selbst zählt +- **AI-/LLM-Sichtbarkeit** – Markennennungen aus Presseartikeln fließen zunehmend in Google AI Overviews und LLM-Antworten ein, wo Sichtbarkeit von Autorität abhängt, nicht nur von klassischen Backlinks + +**Das ist das ehrliche, zukunftsfeste Verkaufsargument:** Sichtbarkeit, Reichweite und Auffindbarkeit – nicht „PageRank kaufen". + +--- + +## 4. Linktypen im Überblick + +|Element|Funktion|`rel` (systemseitig)| +|---|---|---| +|Firmen-Link im PM-Fließtext|Standard, immer möglich|`sponsored` / `nofollow`| +|**Unternehmens-CTA-Box** (Website, Newsroom, Kontakt) am PM-Ende|Hervorhebung, optisch abgesetzt|`sponsored` / `nofollow`| +|Link zur **Landingpage / Produktseite** (extern)|vom Kunden gewählt|`sponsored` / `nofollow`| +|Link zum **Unternehmensprofil auf dem Portal** (intern)|redaktionelle interne Verlinkung|**follow**| + +**Der unterschätzte Punkt – die interne Verlinkung:** Ein follow-Link auf das **portaleigene** Unternehmensprofil ist regelkonform (interner, redaktioneller Link) und stärkt die **eigene** Domain-Architektur. Der Kunde bekommt eine sichtbare „Über das Unternehmen"-Verlinkung; das Portal baut gleichzeitig seine interne Linkstruktur auf, statt fremde Autorität zu verschenken. + +--- + +## 5. Linkangabe & Linktyp-Auswahl im Editor + +**Anforderung:** Beim Erstellen einer PM gibt der Kunde Links an und wählt einen Linktyp. + +**Sauberes Design (Google-konform):** Der Kunde wählt **Ziel und Zweck** des Links – das System leitet daraus die korrekte `rel`-Auszeichnung ab. Die Begriffe „follow/nofollow" tauchen im Kunden-UI **nicht** auf. + +Editor-Flow beim Hinzufügen eines Links: + +1. **Ziel-URL** eingeben +2. **Link-Art** wählen (das ist die kundenseitige „Linktyp"-Auswahl): + +|Link-Art (Auswahl im Editor)|Beispiel|Systemseitige Auszeichnung| +|---|---|---| +|Unternehmens-Website|startseite.de|extern → `sponsored`/`nofollow`| +|Spezifische Landingpage / Produktseite|aktion.startseite.de|extern → `sponsored`/`nofollow`| +|Newsroom / Unternehmensprofil auf dem Portal|(interner Profil-Link)|intern → `follow`| +|Kontakt / Social-Profil|LinkedIn etc.|extern → `sponsored`/`nofollow`| + +3. **Darstellung** (optional, ggf. tarif-/boost-abhängig): Inline-Textlink **oder** hervorgehobene CTA-Box. Das betrifft die _Präsentation_, nicht den Link-Typ. + +**Entscheidung festgehalten:** Die Linktyp-Auswahl steuert **Ziel und Darstellung**, nie das `rel`-Attribut. Das `rel` ist systemgesteuert. Damit hat der Kunde maximale Freiheit bei Ziel und Hervorhebung, ohne dass die Domain-Sicherheit kippt. + +--- + +## 6. Was Produkt ist – und was tabu bleibt + +||Status| +|---|---| +|Links in der PM (extern, korrekt ausgezeichnet)|✅ Standard, kostenlos Teil der Veröffentlichung| +|Hervorgehobene CTA-Box / Linkdarstellung|✅ als Hervorhebungs-/Boost-Feature verkaufbar| +|follow-Link auf portaleigenes Unternehmensprofil|✅ regelkonform, stärkt eigene Domain| +|Sichtbarkeit / Reichweite / Platzierung|✅ das eigentliche Verkaufsargument| +|„Dofollow-Backlink" als bezahltes Upsell|❌ Link-Scheme-Verstoß, Domain-Risiko, gegen Anti-Zombie| +|Kundenseitiger follow/nofollow-Schalter|❌ nicht im UI, `rel` bleibt systemgesteuert| + +--- + +## 7. Anti-Zombie-Check (dieser Stand) + +- ✅ Keine versteckten SEO-Versprechen – verkauft wird Sichtbarkeit, nicht PageRank +- ✅ Domain-Glaubwürdigkeit langfristig geschützt (korrekte `rel`-Auszeichnung systemseitig) +- ✅ Kunde behält volle Freiheit bei Linkziel und Hervorhebung +- ✅ Regelkonform gegenüber Google-Spam-Richtlinien, kein Graubereich +- ✅ Interne Verlinkung baut eigenes Asset statt fremde Autorität zu verschenken + +--- + +## 8. Offene / spätere Punkte + +- **Darstellungs-Stufung:** Soll die hervorgehobene CTA-Box ein eigenes Boost-/Tarif-Feature sein oder Standard? (Hängt am Boost-Konzept.) +- **Anzahl Links pro PM:** Sinnvolle Obergrenze definieren (gegen Link-Spam in der PM selbst, z. B. Profil + Website + 1–2 Kontextlinks). +- **Anchor-Text-Regeln:** Optional späterer Hinweis/Soft-Check gegen übermäßige Exact-Match-Anchors (Spam-Signal), eher Phase 2. + +--- + +## 9. Korrektur am vorherigen Decision-Update + +Im Dokument „Preisstruktur & Veröffentlichungs-Flow", Abschnitt 4, wird der Eintrag + +> „Bewusst ausgeschlossen: Verkaufte Dofollow-Backlinks." + +ersetzt durch: + +> „Verlinkung: Links zur Kundenseite sind Standard-Bestandteil jeder PM, systemseitig als `sponsored`/`nofollow` ausgezeichnet. Hervorhebung der Linkdarstellung ist als Produkt-Feature möglich. **Tabu:** Verkauf von Dofollow-Backlinks und kundenseitige `rel`-Auswahl. Details siehe Decision-Update „Verlinkung & Backlinks"." + +--- + +_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 2024–2026; `nofollow`/`sponsored`/`ugc` als Hinweise seit September 2019. Vor produktiver Umsetzung der `rel`-Logik einmal gegen die dann aktuelle Google-Search-Central-Doku gegenprüfen._ +--- + +## 10. Umsetzungsstand (12.06.2026) + +**Umgesetzt:** + +- `PressReleaseLinkPolicy` (app/Services/PressRelease/): systemseitige + `rel`-Auszeichnung beim Rendern — extern → `sponsored nofollow noopener` + + `target="_blank"`; portalintern (konfigurierte Domains aus + `config/domains.php` inkl. www-Variante, relative Pfade) → **follow**; + `mailto:`/`tel:` → ohne `rel`/`target`. Autoren-`rel` wird immer + überschrieben (kein kundenseitiger Hebel). Greift in + `PressReleaseHtmlSanitizer::render()` und wirkt damit rückwirkend auf + alle gespeicherten Inhalte und auf jede Ausgabe (Panel-Vorschau heute, + Web-Detailseiten beim Relaunch). +- Editor-Hinweis (PM anlegen/bearbeiten): Links erwünscht, Auszeichnung + automatisch — bewusst ohne follow/nofollow-Auswahl im UI. +- §9-Korrektur im Decision-Update „Preisstruktur & Veröffentlichungs-Flow" + (Abschnitt 4) eingearbeitet. + +**Offen (wie in §8 vorgesehen):** CTA-Box/Darstellungs-Stufung (hängt am +Boost-Konzept, 9I), Link-Obergrenze pro PM, Anchor-Text-Soft-Check +(Phase 2). Die Linktyp-Auswahl im Editor ist fürs `rel` nicht nötig +(systemseitig aus der Ziel-URL abgeleitet) — sie wird relevant, sobald die +CTA-Box als Darstellungs-Option kommt. diff --git a/phpunit.xml b/phpunit.xml index 04450c82..fb17dafa 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + diff --git a/tests/Feature/LogChannelsTest.php b/tests/Feature/LogChannelsTest.php new file mode 100644 index 00000000..cc1c370e --- /dev/null +++ b/tests/Feature/LogChannelsTest.php @@ -0,0 +1,69 @@ +toMatchArray([ + 'driver' => 'daily', + 'path' => storage_path("logs/{$file}.log"), + 'days' => 14, + ]); +})->with([ + 'billing' => ['billing', 'billing'], + 'ki' => ['ki', 'ki'], + 'monitoring' => ['monitoring', 'monitoring'], + 'market' => ['market', 'market'], + 'admin_slow' => ['admin_slow', 'admin-slow'], +]); + +test('the default stack rotates daily instead of growing one single file', function () { + $config = require base_path('config/logging.php'); + + expect($config['channels']['stack']['channels'])->toBe(['daily']) + ->and($config['channels']['daily']['path'])->toBe(storage_path('logs/laravel.log')); +}); + +test('the test suite logs into the dedicated testing channel only', function () { + expect(config('logging.default'))->toBe('testing') + ->and(config('logging.channels.testing.path'))->toBe(storage_path('logs/testing.log')); + + foreach (['billing', 'ki', 'monitoring', 'market'] as $channel) { + expect(config("logging.channels.{$channel}.path"))->toBe(storage_path('logs/testing.log')); + } +}); + +test('operational alerts go to the monitoring channel', function () { + config()->set('monitoring.alerts.enabled', false); + $monitoringLog = Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel')->once()->with('monitoring')->andReturn($monitoringLog); + + OperationalAlert::send('Bounce-Postfach konnte nicht gelesen werden'); + + $monitoringLog->shouldHaveReceived('critical') + ->withArgs(fn (string $message): bool => str_contains($message, 'Bounce-Postfach')) + ->once(); +}); + +test('market data failures go to the market channel', function () { + Http::fake(['*' => Http::response('', 500)]); + $marketLog = Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel')->with('market')->andReturn($marketLog); + + app(MarketDataService::class)->refresh(); + + $marketLog->shouldHaveReceived('warning')->atLeast()->once(); +}); diff --git a/tests/Feature/OperationalAlertTest.php b/tests/Feature/OperationalAlertTest.php index eb47b739..dbc1c08f 100644 --- a/tests/Feature/OperationalAlertTest.php +++ b/tests/Feature/OperationalAlertTest.php @@ -6,16 +6,18 @@ use Illuminate\Contracts\Queue\Job; use Illuminate\Queue\Events\JobFailed; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Psr\Log\LoggerInterface; test('an alert always logs critical and mails the configured recipient', function () { config()->set('monitoring.alerts.enabled', true); config()->set('monitoring.alerts.mail_to', 'ops@example.com'); Mail::fake(); - Log::spy(); + $monitoringLog = Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel')->with('monitoring')->andReturn($monitoringLog); OperationalAlert::send('Geplanter Task fehlgeschlagen: billing:generate-manual-invoices', ['exit_code' => 1]); - Log::shouldHaveReceived('critical')->once(); + $monitoringLog->shouldHaveReceived('critical')->once(); Mail::assertSent(OperationalAlertMail::class, function (OperationalAlertMail $mail) { return $mail->hasTo('ops@example.com') && str_contains($mail->summary, 'billing:generate-manual-invoices') diff --git a/tests/TestCase.php b/tests/TestCase.php index f9a75660..69eabf7c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,5 +23,23 @@ abstract class TestCase extends BaseTestCase config('database.default'), )); } + + $this->routeTopicLogChannelsToTestingLog(); + } + + /** + * Die Themen-Channels (`Log::channel('billing')` usw.) umgehen den + * Default-Channel aus phpunit.xml und würden sonst in die echten + * Themen-Dateien unter storage/logs schreiben. Alles, was die Suite loggt, + * gehört nach testing-YYYY-MM-DD.log. `admin_slow` bleibt außen vor, weil + * seine Tests den Pfad selbst setzen bzw. prüfen. + */ + private function routeTopicLogChannelsToTestingLog(): void + { + $testing = config('logging.channels.testing'); + + foreach (['billing', 'ki', 'monitoring', 'market'] as $channel) { + config()->set("logging.channels.{$channel}", $testing); + } } }