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); LivewireVolt::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); });