create(['portal' => Portal::Businessportal24]); $company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); return PressRelease::factory() ->published() ->forPortal(Portal::Businessportal24) ->for($category) ->for($company) ->create(array_merge(['slug' => 'tracking-pm', 'published_at' => now()->subDay()], $attributes)); } test('release detail logs a view event without writing to the press release table', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 4]); $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); $event = PressReleaseEvent::query()->sole(); expect($event->press_release_id)->toBe($release->id) ->and($event->type)->toBe(PressReleaseEventType::View) ->and($event->channel)->toBeNull(); // Der Request fasst press_releases NICHT an — ein blockierender // Schreibzugriff auf die große Tabelle hat früher Worker gebunden. expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(4); // Erst der Scheduler verbucht den Aufruf im Gesamtzähler. $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(5); }); test('view aggregation is idempotent and does not double count', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 0]); $url = 'https://businessportal24.test/de/press-release/'.$release->slug; $this->get($url)->assertSuccessful(); $this->get($url)->assertSuccessful(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2); // Zweiter Lauf ohne neue Ereignisse darf nichts verändern — die // Wasserstandsmarke steht bereits hinter den verbuchten Aufrufen. $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2); // Ein weiterer Aufruf wird beim nächsten Lauf nachgetragen. $this->get($url)->assertSuccessful(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(3); }); test('an existing hit count is never counted twice through the event log', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 1000]); // Bestand: Ereignisse, die bereits in `hits` stecken. PressReleaseEvent::factory()->count(5)->create([ 'press_release_id' => $release->id, 'type' => PressReleaseEventType::View, ]); // Genau das tut die Migration beim Deploy: Marke auf den Ist-Stand. AppSetting::setValue( AggregatePressReleaseViews::WatermarkKey, (int) PressReleaseEvent::query()->max('id'), ); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1000); // Nur neue Aufrufe kommen hinzu. $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1001); }); test('a missing watermark initialises instead of counting the backlog twice', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 1000]); PressReleaseEvent::factory()->count(4)->create([ 'press_release_id' => $release->id, 'type' => PressReleaseEventType::View, ]); // Zustand im Deploy-Fenster: Code läuft schon, Migration noch nicht — // der Scheduler startet den Task ohne gesetzte Marke. AppSetting::query()->whereKey(AggregatePressReleaseViews::WatermarkKey)->delete(); Cache::forget('app-setting.'.AggregatePressReleaseViews::WatermarkKey); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); // Der Bestand darf NICHT ein zweites Mal aufaddiert werden. expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1000); // Ab jetzt zählt nur noch, was neu hinzukommt. $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1001); }); test('view aggregation ignores share events and walks through in chunks', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 0]); PressReleaseEvent::factory()->count(7)->create([ 'press_release_id' => $release->id, 'type' => PressReleaseEventType::View, ]); PressReleaseEvent::factory()->count(3)->create([ 'press_release_id' => $release->id, 'type' => PressReleaseEventType::Share, 'channel' => PressReleaseShareChannel::Email, ]); // Chunk kleiner als die Ereignismenge: erzwingt mehrere Durchgänge. $this->artisan('press-releases:aggregate-views', ['--chunk' => 100])->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(7); }); test('release detail shows the public view count', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 1233]); // Anzeige inkl. des aktuellen Aufrufs (1234). $this->get('https://businessportal24.test/de/press-release/'.$release->slug) ->assertSuccessful() ->assertSeeText('1.234 Aufrufe'); }); test('share endpoint logs a share event with its channel', function () { /** @var TestCase $this */ $release = trackingRelease(); $this->postJson('https://businessportal24.test/de/press-release/'.$release->slug.'/share', [ 'channel' => 'linkedin', ])->assertNoContent(); $event = PressReleaseEvent::query()->sole(); expect($event->press_release_id)->toBe($release->id) ->and($event->type)->toBe(PressReleaseEventType::Share) ->and($event->channel)->toBe(PressReleaseShareChannel::LinkedIn); }); test('share endpoint rejects unknown channels', function () { /** @var TestCase $this */ $release = trackingRelease(); $this->postJson('https://businessportal24.test/de/press-release/'.$release->slug.'/share', [ 'channel' => 'myspace', ])->assertUnprocessable(); expect(PressReleaseEvent::query()->count())->toBe(0); }); test('share endpoint returns 404 for drafts and unknown slugs', function () { /** @var TestCase $this */ $category = Category::factory()->create(['portal' => Portal::Businessportal24]); $company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); $draft = PressRelease::factory() ->forPortal(Portal::Businessportal24) ->for($category) ->for($company) ->create(['status' => PressReleaseStatus::Draft, 'slug' => 'entwurf-pm']); $this->postJson('https://businessportal24.test/de/press-release/'.$draft->slug.'/share', [ 'channel' => 'x', ])->assertNotFound(); $this->postJson('https://businessportal24.test/de/press-release/gibt-es-nicht/share', [ 'channel' => 'x', ])->assertNotFound(); expect(PressReleaseEvent::query()->count())->toBe(0); }); test('customer panel shows view and share stats for the owner', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); $customer = User::factory()->create(['is_active' => true]); $customer->assignRole('customer'); $company = Company::factory()->presseecho()->create(); $customer->companies()->attach($company->id, ['role' => 'owner']); $release = PressRelease::factory()->published()->create([ 'user_id' => $customer->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'hits' => 42, ]); PressReleaseEvent::factory()->share(PressReleaseShareChannel::LinkedIn)->for($release)->count(2)->create(); PressReleaseEvent::factory()->share(PressReleaseShareChannel::CopyLink)->for($release)->create(); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $release->id]) ->assertSee('Aufrufe') ->assertSee('42') ->assertSee('Geteilt') ->assertSee('LinkedIn 2') ->assertSee('Link kopiert 1'); }); test('deleting a press release removes its tracked events', function () { $release = trackingRelease(); PressReleaseEvent::factory()->for($release)->create(); PressReleaseEvent::factory()->share(PressReleaseShareChannel::X)->for($release)->create(); PressRelease::withoutGlobalScopes()->find($release->id)->forceDelete(); expect(PressReleaseEvent::query()->count())->toBe(0); }); test('a second aggregation run is refused while another one holds the lock (Audit F19)', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 0]); $url = 'https://businessportal24.test/de/press-release/'.$release->slug; $this->get($url)->assertSuccessful(); $lock = Cache::lock(AggregatePressReleaseViews::LockKey, 60); expect($lock->get())->toBeTrue(); // Manueller Lauf parallel zum Scheduler: nichts verbuchen, nicht abstürzen. $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0); $lock->release(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1); }); test('the aggregation stops at its time budget and continues on the next run (Audit U13)', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 0]); $url = 'https://businessportal24.test/de/press-release/'.$release->slug; $this->get($url)->assertSuccessful(); $this->get($url)->assertSuccessful(); $this->get($url)->assertSuccessful(); // Budget 0: der erste Durchgang läuft gar nicht erst an. $this->artisan('press-releases:aggregate-views', ['--budget' => 0]) ->expectsOutputToContain('Zeitbudget') ->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(3); }); test('a run whose watermark was moved by another run books nothing twice (Audit U13)', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 0]); $url = 'https://businessportal24.test/de/press-release/'.$release->slug; $this->get($url)->assertSuccessful(); $latest = (int) PressReleaseEvent::query()->max('id'); $moved = false; // Nachdem der Lauf seinen Durchgang gezählt hat, aber bevor er verbucht, // schiebt ein „anderer Lauf“ die Marke bereits über dieses Ereignis. DB::listen(function ($query) use (&$moved, $latest): void { if (! $moved && str_contains($query->sql, 'count(*) as views')) { $moved = true; DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->update(['value' => json_encode($latest)]); } }); $this->artisan('press-releases:aggregate-views')->assertFailed(); expect($moved)->toBeTrue() ->and(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0) ->and((int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'))->toBe($latest); }); test('a stale cached watermark neither stops the aggregation nor counts a view twice (Audit N21)', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 0]); $url = 'https://businessportal24.test/de/press-release/'.$release->slug; $cacheKey = 'app-setting.'.AggregatePressReleaseViews::WatermarkKey; $this->get($url)->assertSuccessful(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); $booked = (int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1); // Ein Leser hat die alte Marke zwischen Cache-Forget und Commit zurück in // den Cache geschrieben — früher scheiterte ab hier jeder Lauf dauerhaft. Cache::forever($cacheKey, 0); $this->get($url)->assertSuccessful(); $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2) ->and((int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'))->toBeGreaterThan($booked) ->and(Cache::get($cacheKey))->toBeNull(); // Und der Folgelauf zählt nichts nach. $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2); });