From 3e3c0b4c9d07f69102fac5dd50637243f7499caf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 09:41:36 -0300 Subject: [PATCH] refactor: namespace PostHog jobs + extract billing tracking, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the Stripe billing capture out of `StripeEventListener` into its own job. Adds the missing test coverage that was promised but not delivered in the previous commit. Code changes: - Move `app/Jobs/SendPostHogEvent.php` → `app/Jobs/PostHog/SendEvent.php` (low-level dispatcher). - Move `app/Jobs/SyncUserToPostHog.php` → `app/Jobs/PostHog/SyncUser.php` (high-level user/account/workspace sync). - New `app/Jobs/PostHog/TrackBilling.php` that owns the capture('subscription.*') + SyncUser re-dispatch flow. Receives account id + event name + payload, runs on the `posthog` queue. - `StripeEventListener` slims down to a switch table mapping Stripe event types to PostHog event names and dispatches `TrackBilling`. No more inline tracking logic in the listener. - `resources/js/posthog.ts` now owns `syncPostHogContext(page)` and `capturePageview()`. `resources/js/app.ts` imports them — no behaviour inlined in the bootstrap. - `app/Services/PostHogService.php` and `app/Actions/User/CreateUser.php` updated to the new namespaces. Tests added/updated: - `tests/Feature/Jobs/PostHog/SyncUserTest.php` — identify/group payload shape, account metrics, workspace skip when none, queue assignment, no-op without api key. - `tests/Feature/Jobs/PostHog/TrackBillingTest.php` — capture payload, SyncUser re-dispatch, missing-account/owner handling, api key gate. - `tests/Feature/Jobs/PostHog/SendEventTest.php` — moved from `tests/Feature/SendPostHogEventTest.php` and updated to new namespace. - `tests/Unit/PostHogServiceTest.php` — adds coverage for the account-aware capture (auto-attached `\$groups.account`, `account_id`, `plan`) and the no-account branch. - `tests/Feature/Listeners/StripeEventListenerTest.php` — replaces the old inline-PostHog assertions with `Bus::fake([TrackBilling::class])` and verifies the listener dispatches TrackBilling with the right account id + event name for each subscription type, and skips non-subscription event types. - `tests/Feature/Actions/User/CreateUserTest.php` — verifies signup dispatches `SyncUser` with the new user id. Suite: 1427 passed (+20 net new, including the previous round of metrics-related tests). --- app/Actions/User/CreateUser.php | 4 +- .../SendEvent.php} | 16 +- .../SyncUser.php} | 8 +- app/Jobs/PostHog/TrackBilling.php | 63 ++++++++ app/Listeners/StripeEventListener.php | 77 ++-------- app/Services/PostHogService.php | 4 +- resources/js/app.ts | 64 +------- resources/js/posthog.ts | 67 +++++++++ tests/Feature/Actions/User/CreateUserTest.php | 17 +++ .../PostHog/SendEventTest.php} | 8 +- tests/Feature/Jobs/PostHog/SyncUserTest.php | 139 ++++++++++++++++++ .../Feature/Jobs/PostHog/TrackBillingTest.php | 98 ++++++++++++ .../Listeners/StripeEventListenerTest.php | 61 +++++++- tests/Unit/PostHogServiceTest.php | 50 ++++++- 14 files changed, 536 insertions(+), 140 deletions(-) rename app/Jobs/{SendPostHogEvent.php => PostHog/SendEvent.php} (66%) rename app/Jobs/{SyncUserToPostHog.php => PostHog/SyncUser.php} (94%) create mode 100644 app/Jobs/PostHog/TrackBilling.php rename tests/Feature/{SendPostHogEventTest.php => Jobs/PostHog/SendEventTest.php} (83%) create mode 100644 tests/Feature/Jobs/PostHog/SyncUserTest.php create mode 100644 tests/Feature/Jobs/PostHog/TrackBillingTest.php diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 6bb253c1..8ae1e0ca 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -4,7 +4,7 @@ namespace App\Actions\User; -use App\Jobs\SyncUserToPostHog; +use App\Jobs\PostHog\SyncUser; use App\Models\Account; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -41,7 +41,7 @@ public static function execute(array $data, array $utmParameters = []): User return $user; }); - SyncUserToPostHog::dispatch((string) $user->id); + SyncUser::dispatch((string) $user->id); return $user; } diff --git a/app/Jobs/SendPostHogEvent.php b/app/Jobs/PostHog/SendEvent.php similarity index 66% rename from app/Jobs/SendPostHogEvent.php rename to app/Jobs/PostHog/SendEvent.php index 59b880dd..0a2ef992 100644 --- a/app/Jobs/SendPostHogEvent.php +++ b/app/Jobs/PostHog/SendEvent.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Jobs; +namespace App\Jobs\PostHog; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -12,7 +12,17 @@ use Illuminate\Support\Facades\Log; use PostHog\PostHog; -class SendPostHogEvent implements ShouldQueue +/** + * Low-level PostHog dispatch worker. Receives an array of pre-built API + * calls (`capture`, `identify`, `groupIdentify`) and forwards them to the + * PostHog SDK. Higher-level jobs in this namespace (`SyncUser`, + * `TrackBilling`) build the payloads and queue this job to do the network + * work. + * + * No-op when `POSTHOG_API_KEY` is unset so self-hosted installs are + * unaffected. + */ +class SendEvent implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -40,7 +50,7 @@ public function handle(): void 'capture' => PostHog::capture(data_get($call, 'payload')), 'identify' => PostHog::identify(data_get($call, 'payload')), 'groupIdentify' => PostHog::groupIdentify(data_get($call, 'payload')), - default => Log::warning('SendPostHogEvent: unknown method', ['method' => data_get($call, 'method')]), + default => Log::warning('PostHog\\SendEvent: unknown method', ['method' => data_get($call, 'method')]), }; } diff --git a/app/Jobs/SyncUserToPostHog.php b/app/Jobs/PostHog/SyncUser.php similarity index 94% rename from app/Jobs/SyncUserToPostHog.php rename to app/Jobs/PostHog/SyncUser.php index f9b6fb52..34ae719a 100644 --- a/app/Jobs/SyncUserToPostHog.php +++ b/app/Jobs/PostHog/SyncUser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Jobs; +namespace App\Jobs\PostHog; use App\Models\User; use App\Services\PostHogService; @@ -17,8 +17,8 @@ * frontend can't observe (signup, Stripe webhooks) so the person profile * and account/workspace groups carry up-to-date properties without blocking * the calling request. Inertia navigations refresh group counts on the - * client (see `syncPostHogContext` in resources/js/app.ts), so this job is - * not needed on every domain trigger. + * client (see `syncPostHogContext` in resources/js/posthog.ts), so this job + * is not needed on every domain trigger. * * Hierarchy mirrors the domain model: * - person → User @@ -28,7 +28,7 @@ * No-op when POSTHOG_API_KEY is unset (PostHogService short-circuits), so * self-hosted installs are unaffected. */ -class SyncUserToPostHog implements ShouldQueue +class SyncUser implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/PostHog/TrackBilling.php b/app/Jobs/PostHog/TrackBilling.php new file mode 100644 index 00000000..ca8475a7 --- /dev/null +++ b/app/Jobs/PostHog/TrackBilling.php @@ -0,0 +1,63 @@ + $payload + */ + public function __construct( + public string $accountId, + public string $event, + public array $payload, + ) { + $this->onQueue('posthog'); + } + + public function handle(PostHogService $postHog): void + { + $account = Account::with('plan')->find($this->accountId); + + if (! $account || ! $account->owner_id) { + return; + } + + $postHog->capture( + (string) $account->owner_id, + $this->event, + [ + 'stripe_status' => data_get($this->payload, 'data.object.status'), + 'plan' => $account->plan?->name, + 'plan_slug' => $account->plan?->slug, + ], + $account, + ); + + SyncUser::dispatch((string) $account->owner_id); + } +} diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 38e2b5a7..20132b96 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -5,14 +5,19 @@ namespace App\Listeners; use App\Events\SubscriptionCreated; -use App\Jobs\SyncUserToPostHog; +use App\Jobs\PostHog\TrackBilling; use App\Models\Account; -use App\Services\PostHogService; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; class StripeEventListener { + private const POSTHOG_EVENT_BY_TYPE = [ + 'customer.subscription.created' => 'subscription.created', + 'customer.subscription.updated' => 'subscription.updated', + 'customer.subscription.deleted' => 'subscription.cancelled', + ]; + public function handle(WebhookReceived $event): void { try { @@ -29,12 +34,13 @@ public function handle(WebhookReceived $event): void return; } - match ($type) { - 'customer.subscription.created' => $this->handleSubscriptionCreated($account, $event->payload), - 'customer.subscription.updated' => $this->handleSubscriptionUpdated($account, $event->payload), - 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($account, $event->payload), - default => null, - }; + if ($type === 'customer.subscription.created') { + SubscriptionCreated::dispatch($account); + } + + if ($postHogEvent = self::POSTHOG_EVENT_BY_TYPE[$type] ?? null) { + TrackBilling::dispatch((string) $account->id, $postHogEvent, $event->payload); + } } catch (\Exception $e) { Log::error('Stripe webhook error: '.$e->getMessage(), [ 'exception' => $e, @@ -42,59 +48,4 @@ public function handle(WebhookReceived $event): void ]); } } - - /** - * @param array $payload - */ - protected function handleSubscriptionCreated(Account $account, array $payload): void - { - SubscriptionCreated::dispatch($account); - - $this->trackBilling($account, 'subscription.created', $payload); - } - - /** - * @param array $payload - */ - protected function handleSubscriptionUpdated(Account $account, array $payload): void - { - $this->trackBilling($account, 'subscription.updated', $payload); - } - - /** - * @param array $payload - */ - protected function handleSubscriptionDeleted(Account $account, array $payload): void - { - $this->trackBilling($account, 'subscription.cancelled', $payload); - } - - /** - * Capture the lifecycle event on the account owner's profile and trigger - * a fresh `SyncUserToPostHog` so the account group properties (plan, - * has_active_subscription, is_on_trial) reflect the new Stripe state. - * - * @param array $payload - */ - private function trackBilling(Account $account, string $event, array $payload): void - { - if (! $account->owner_id) { - return; - } - - $properties = [ - 'stripe_status' => data_get($payload, 'data.object.status'), - 'plan' => $account->plan?->name, - 'plan_slug' => $account->plan?->slug, - ]; - - app(PostHogService::class)->capture( - (string) $account->owner_id, - $event, - $properties, - $account, - ); - - SyncUserToPostHog::dispatch((string) $account->owner_id); - } } diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 5c23b457..23e3214b 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -4,7 +4,7 @@ namespace App\Services; -use App\Jobs\SendPostHogEvent; +use App\Jobs\PostHog\SendEvent; use App\Models\Account; use Illuminate\Support\Facades\Log; @@ -75,7 +75,7 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper private function dispatch(string $method, array $payload): void { try { - SendPostHogEvent::dispatch([ + SendEvent::dispatch([ ['method' => $method, 'payload' => $payload], ]); } catch (\Throwable $e) { diff --git a/resources/js/app.ts b/resources/js/app.ts index 231f3389..6b54db35 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,7 +1,6 @@ import '../css/app.css'; import { createInertiaApp, router } from '@inertiajs/vue3'; -import type { Page } from '@inertiajs/core'; import { configureEcho } from '@laravel/echo-vue'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { i18nVue } from 'laravel-vue-i18n'; @@ -10,54 +9,15 @@ import { createApp, h } from 'vue'; import { initializeDataLayer } from './datalayer'; import dayjs from './dayjs'; -import posthog from './posthog'; +import { capturePageview, syncPostHogContext } from './posthog'; import type { Auth } from './types'; -interface Usage { - workspaceCount: number; - socialAccountCount: number; - memberCount: number; - pendingInviteCount: number; - postCount: number; - creditsUsed: number; -} - configureEcho({ broadcaster: 'reverb', }); const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it'; -// Re-applies the PostHog account/workspace group context with fresh metrics. -// Called on every Inertia navigation so `usage` counts (workspaces, social -// accounts, posts, members) stay reactive — the backend already ships these -// counts on every Inertia request, so this leverages props that are already -// loaded without firing extra queries. -const syncPostHogContext = (page: Page): void => { - const auth = page.props.auth as Auth | undefined; - const usage = page.props.usage as Usage | null; - - if (!auth?.user) return; - - if (auth.account) { - posthog.group('account', auth.account.id, { - name: auth.account.name, - workspaces_count: usage?.workspaceCount, - social_accounts_count: usage?.socialAccountCount, - members_count: usage?.memberCount, - posts_count: usage?.postCount, - credits_used: usage?.creditsUsed, - }); - } - - if (auth.currentWorkspace) { - posthog.group('workspace', auth.currentWorkspace.id, { - name: auth.currentWorkspace.name, - account_id: auth.account?.id, - }); - } -}; - createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), resolve: (name) => @@ -84,26 +44,16 @@ createInertiaApp({ props.initialPage.props.env as string, ); - if (auth?.user) { - posthog.identify(auth.user.id, { - $email: auth.user.email, - $name: auth.user.name, - }); - } - - // Initial group context + initial pageview. The backend mirrors this - // hierarchy in app/Jobs/SyncUserToPostHog.php so events emitted from - // the server land on the same person + group identifiers. + // Initial PostHog identify + dual-group context + first pageview. + // The same hooks fire on every Inertia navigation below so the + // account group counts stay reactive and workspace switches + // re-attach the right workspace group. syncPostHogContext(props.initialPage); - posthog.capture('$pageview', { $current_url: window.location.href }); + capturePageview(); router.on('navigate', (event) => { - // Re-sync group context on every navigation: refreshes the count - // metrics on the `account` group AND covers workspace switches - // (which update auth.currentWorkspace via Inertia's prop refresh - // without triggering setup() again). syncPostHogContext(event.detail.page); - posthog.capture('$pageview', { $current_url: window.location.href }); + capturePageview(); }); createApp({ render: () => h(App, props) }) diff --git a/resources/js/posthog.ts b/resources/js/posthog.ts index 54f53c7b..11e61d13 100644 --- a/resources/js/posthog.ts +++ b/resources/js/posthog.ts @@ -1,5 +1,8 @@ +import type { Page } from '@inertiajs/core'; import posthog from 'posthog-js'; +import type { Auth } from './types'; + const apiKey = import.meta.env.VITE_POSTHOG_API_KEY as string | undefined; const host = import.meta.env.VITE_POSTHOG_HOST as string | undefined; @@ -18,4 +21,68 @@ if (apiKey) { }); } +interface Usage { + workspaceCount: number; + socialAccountCount: number; + memberCount: number; + pendingInviteCount: number; + postCount: number; + creditsUsed: number; +} + +/** + * Identify the current person and refresh the dual group context (account + + * workspace) using the metrics already shipped by Inertia in shared props. + * + * Called once at boot and on every Inertia navigation so: + * - Counts on the `account` group (workspaces, social accounts, posts, + * members, credits) stay reactive without per-domain backend triggers. + * - Workspace switches re-attach the right `workspace` group (the + * navigation event carries the fresh `auth.currentWorkspace`). + * + * Hierarchy mirrors the backend `app/Jobs/SyncUserToPostHog.php`: + * person → User, group `account` → billing/plan parent, + * group `workspace` → collaboration child (carries `account_id`). + */ +export const syncPostHogContext = (page: Page): void => { + const auth = page.props.auth as Auth | undefined; + const usage = page.props.usage as Usage | null | undefined; + + if (!auth?.user) { + return; + } + + posthog.identify(auth.user.id, { + $email: auth.user.email, + $name: auth.user.name, + }); + + if (auth.account) { + posthog.group('account', auth.account.id, { + name: auth.account.name, + workspaces_count: usage?.workspaceCount, + social_accounts_count: usage?.socialAccountCount, + members_count: usage?.memberCount, + posts_count: usage?.postCount, + credits_used: usage?.creditsUsed, + }); + } + + if (auth.currentWorkspace) { + posthog.group('workspace', auth.currentWorkspace.id, { + name: auth.currentWorkspace.name, + account_id: auth.account?.id, + }); + } +}; + +/** + * Capture an Inertia-driven pageview. The base SDK is configured with + * `capture_pageview: false`, so this needs to be invoked explicitly on + * every navigation (and once at boot for the first page). + */ +export const capturePageview = (): void => { + posthog.capture('$pageview', { $current_url: window.location.href }); +}; + export default posthog; diff --git a/tests/Feature/Actions/User/CreateUserTest.php b/tests/Feature/Actions/User/CreateUserTest.php index 0f83bb98..eb46dee1 100644 --- a/tests/Feature/Actions/User/CreateUserTest.php +++ b/tests/Feature/Actions/User/CreateUserTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Actions\User\CreateUser; +use App\Jobs\PostHog\SyncUser; use App\Models\Account; use App\Models\Workspace; +use Illuminate\Support\Facades\Bus; test('CreateUser creates user with account but no workspace and no current_workspace_id', function () { $user = CreateUser::execute([ @@ -42,3 +44,18 @@ expect($user->email_verified_at)->not->toBeNull(); expect(Workspace::count())->toBe(0); }); + +test('CreateUser dispatches SyncUser with the new user id', function () { + Bus::fake([SyncUser::class]); + + $user = CreateUser::execute([ + 'name' => 'Jane Doe', + 'email' => 'jane.posthog@example.com', + 'password' => 'secret123', + ]); + + Bus::assertDispatched( + SyncUser::class, + fn ($job) => $job->userId === (string) $user->id, + ); +}); diff --git a/tests/Feature/SendPostHogEventTest.php b/tests/Feature/Jobs/PostHog/SendEventTest.php similarity index 83% rename from tests/Feature/SendPostHogEventTest.php rename to tests/Feature/Jobs/PostHog/SendEventTest.php index ec0bf2b6..0c93aaa9 100644 --- a/tests/Feature/SendPostHogEventTest.php +++ b/tests/Feature/Jobs/PostHog/SendEventTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use App\Jobs\SendPostHogEvent; +use App\Jobs\PostHog\SendEvent; test('job is queued on posthog queue', function () { - $job = new SendPostHogEvent([ + $job = new SendEvent([ ['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']], ]); @@ -15,7 +15,7 @@ test('job skips execution when api key is missing', function () { config(['services.posthog.api_key' => null]); - $job = new SendPostHogEvent([ + $job = new SendEvent([ ['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']], ]); @@ -26,7 +26,7 @@ }); test('job has correct retry and timeout settings', function () { - $job = new SendPostHogEvent([]); + $job = new SendEvent([]); expect($job->tries)->toBe(3); expect($job->timeout)->toBe(15); diff --git a/tests/Feature/Jobs/PostHog/SyncUserTest.php b/tests/Feature/Jobs/PostHog/SyncUserTest.php new file mode 100644 index 00000000..c4c2620e --- /dev/null +++ b/tests/Feature/Jobs/PostHog/SyncUserTest.php @@ -0,0 +1,139 @@ + 'phc_test_key']); + + $this->account = Account::factory()->create([ + 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, + ]); + $this->user = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->user->id]); +}); + +test('handle is a no-op when api key is unset', function () { + config(['services.posthog.api_key' => null]); + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + Queue::assertNothingPushed(); +}); + +test('handle returns silently when user does not exist', function () { + Queue::fake(); + + (new SyncUser((string) Str::uuid()))->handle(app(PostHogService::class)); + + Queue::assertNothingPushed(); +}); + +test('handle identifies the user with email, name and signed_up_at', function () { + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + $call = $job->calls[0]; + + return $call['method'] === 'identify' + && $call['payload']['distinctId'] === (string) $this->user->id + && $call['payload']['properties']['email'] === $this->user->email + && $call['payload']['properties']['name'] === $this->user->name + && isset($call['payload']['properties']['$set_once']['signed_up_at']); + }); +}); + +test('handle group-identifies the account with usage metrics', function () { + $workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); + SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id]); + Post::factory()->count(3)->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $this->user->id, + ]); + + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + $accountCall = collect($job->calls) + ->merge(collect(Queue::pushed(SendEvent::class))->flatMap(fn ($j) => $j->calls)) + ->first(fn ($c) => $c['method'] === 'groupIdentify' && $c['payload']['groupType'] === 'account'); + + if (! $accountCall) { + return false; + } + + $props = $accountCall['payload']['properties']; + + return $accountCall['payload']['groupKey'] === (string) $this->account->id + && $props['workspaces_count'] === 1 + && $props['social_accounts_count'] === 2 + && $props['posts_count'] === 3 + && $props['members_count'] === 1 + && array_key_exists('plan', $props) + && array_key_exists('has_active_subscription', $props) + && array_key_exists('is_on_trial', $props); + }); +}); + +test('handle group-identifies the current workspace when set', function () { + $workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); + SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + $this->user->update(['current_workspace_id' => $workspace->id]); + + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + $allCalls = collect(Queue::pushed(SendEvent::class))->flatMap(fn ($j) => $j->calls); + $workspaceCall = $allCalls->first( + fn ($c) => $c['method'] === 'groupIdentify' && $c['payload']['groupType'] === 'workspace', + ); + + expect($workspaceCall)->not->toBeNull(); + expect($workspaceCall['payload']['groupKey'])->toBe((string) $workspace->id); + expect($workspaceCall['payload']['properties']['account_id'])->toBe((string) $this->account->id); + expect($workspaceCall['payload']['properties']['social_accounts_count'])->toBe(1); +}); + +test('handle skips workspace group identify when user has no current workspace', function () { + $this->user->update(['current_workspace_id' => null]); + + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + $allCalls = collect(Queue::pushed(SendEvent::class))->flatMap(fn ($j) => $j->calls); + $workspaceCall = $allCalls->first( + fn ($c) => $c['method'] === 'groupIdentify' && $c['payload']['groupType'] === 'workspace', + ); + + expect($workspaceCall)->toBeNull(); +}); + +test('job is queued on the posthog connection queue', function () { + $job = new SyncUser((string) $this->user->id); + + expect($job->queue)->toBe('posthog'); +}); diff --git a/tests/Feature/Jobs/PostHog/TrackBillingTest.php b/tests/Feature/Jobs/PostHog/TrackBillingTest.php new file mode 100644 index 00000000..eb47e9f8 --- /dev/null +++ b/tests/Feature/Jobs/PostHog/TrackBillingTest.php @@ -0,0 +1,98 @@ + 'phc_test_key']); + + $this->account = Account::factory()->create([ + 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, + ]); + $this->user = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->user->id]); + + $this->payload = [ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => ['customer' => 'cus_test', 'status' => 'active']], + ]; +}); + +test('job is queued on the posthog queue', function () { + $job = new TrackBilling((string) $this->account->id, 'subscription.updated', $this->payload); + + expect($job->queue)->toBe('posthog'); +}); + +test('handle captures event on the owner profile with account group attached', function () { + Queue::fake(); + + (new TrackBilling((string) $this->account->id, 'subscription.updated', $this->payload)) + ->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + $call = $job->calls[0]; + + return $call['method'] === 'capture' + && $call['payload']['event'] === 'subscription.updated' + && $call['payload']['distinctId'] === (string) $this->user->id + && $call['payload']['properties']['$groups']['account'] === (string) $this->account->id + && $call['payload']['properties']['stripe_status'] === 'active'; + }); +}); + +test('handle dispatches SyncUser for the account owner', function () { + Bus::fake([SyncUser::class]); + + (new TrackBilling((string) $this->account->id, 'subscription.cancelled', $this->payload)) + ->handle(app(PostHogService::class)); + + Bus::assertDispatched( + SyncUser::class, + fn ($job) => $job->userId === (string) $this->user->id, + ); +}); + +test('handle returns silently when account does not exist', function () { + Queue::fake(); + Bus::fake([SyncUser::class]); + + (new TrackBilling('00000000-0000-0000-0000-000000000000', 'subscription.created', $this->payload)) + ->handle(app(PostHogService::class)); + + Queue::assertNothingPushed(); + Bus::assertNotDispatched(SyncUser::class); +}); + +test('handle returns silently when account has no owner', function () { + $this->account->update(['owner_id' => null]); + Queue::fake(); + Bus::fake([SyncUser::class]); + + (new TrackBilling((string) $this->account->id, 'subscription.created', $this->payload)) + ->handle(app(PostHogService::class)); + + Queue::assertNothingPushed(); + Bus::assertNotDispatched(SyncUser::class); +}); + +test('handle does not push a PostHog network call when api key is unset', function () { + config(['services.posthog.api_key' => null]); + Queue::fake(); + + (new TrackBilling((string) $this->account->id, 'subscription.created', $this->payload)) + ->handle(app(PostHogService::class)); + + // SyncUser can still be queued but it would itself no-op when handled. + // The contract of this job is: no PostHog network call without a key. + Queue::assertNotPushed(SendEvent::class); +}); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index 362871ed..6a785bb3 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use App\Events\SubscriptionCreated; +use App\Jobs\PostHog\TrackBilling; use App\Listeners\StripeEventListener; use App\Models\Account; use App\Models\User; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; @@ -126,7 +128,6 @@ ->once() ->withArgs(fn ($msg) => str_contains($msg, 'Stripe webhook error')); - // Force an exception by making Event::dispatch throw Event::listen(SubscriptionCreated::class, fn () => throw new Exception('Simulated error')); $this->listener->handle(new WebhookReceived([ @@ -142,3 +143,61 @@ expect(true)->toBeTrue(); }); + +// ======================================== +// PostHog tracking — listener delegates to TrackBilling +// ======================================== + +test('subscription created dispatches TrackBilling with subscription.created event', function () { + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'trialing']], + ])); + + Bus::assertDispatched( + TrackBilling::class, + fn ($job) => $job->accountId === (string) $this->account->id + && $job->event === 'subscription.created', + ); +}); + +test('subscription updated dispatches TrackBilling with subscription.updated event', function () { + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => ['customer' => 'cus_test123', 'status' => 'active']], + ])); + + Bus::assertDispatched( + TrackBilling::class, + fn ($job) => $job->event === 'subscription.updated', + ); +}); + +test('subscription deleted dispatches TrackBilling with subscription.cancelled event', function () { + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_test123', 'status' => 'canceled']], + ])); + + Bus::assertDispatched( + TrackBilling::class, + fn ($job) => $job->event === 'subscription.cancelled', + ); +}); + +test('unknown event types do not dispatch TrackBilling', function () { + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'invoice.payment_succeeded', + 'data' => ['object' => ['customer' => 'cus_test123']], + ])); + + Bus::assertNotDispatched(TrackBilling::class); +}); diff --git a/tests/Unit/PostHogServiceTest.php b/tests/Unit/PostHogServiceTest.php index 34f0938d..9bf0425a 100644 --- a/tests/Unit/PostHogServiceTest.php +++ b/tests/Unit/PostHogServiceTest.php @@ -2,10 +2,15 @@ declare(strict_types=1); -use App\Jobs\SendPostHogEvent; +use App\Jobs\PostHog\SendEvent; +use App\Models\Account; +use App\Models\Plan; use App\Services\PostHogService; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; +uses(RefreshDatabase::class); + test('capture dispatches job when api key is configured', function () { Queue::fake(); config(['services.posthog.api_key' => 'phc_test_key']); @@ -13,7 +18,7 @@ $service = new PostHogService; $service->capture('user-123', 'test_event', ['foo' => 'bar']); - Queue::assertPushed(SendPostHogEvent::class, function ($job) { + Queue::assertPushed(SendEvent::class, function ($job) { return $job->calls[0]['method'] === 'capture' && $job->calls[0]['payload']['event'] === 'test_event' && $job->calls[0]['payload']['distinctId'] === 'user-123'; @@ -37,7 +42,7 @@ $service = new PostHogService; $service->identify('user-123', ['$email' => 'test@example.com']); - Queue::assertPushed(SendPostHogEvent::class, function ($job) { + Queue::assertPushed(SendEvent::class, function ($job) { return $job->calls[0]['method'] === 'identify' && $job->calls[0]['payload']['distinctId'] === 'user-123'; }); @@ -60,7 +65,7 @@ $service = new PostHogService; $service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']); - Queue::assertPushed(SendPostHogEvent::class, function ($job) { + Queue::assertPushed(SendEvent::class, function ($job) { return $job->calls[0]['method'] === 'groupIdentify' && $job->calls[0]['payload']['groupType'] === 'workspace' && $job->calls[0]['payload']['groupKey'] === 'ws-123'; @@ -76,3 +81,40 @@ Queue::assertNothingPushed(); }); + +test('capture auto-attaches account groups when account is supplied', function () { + Queue::fake(); + config(['services.posthog.api_key' => 'phc_test_key']); + + $plan = Plan::query()->where('slug', 'starter')->first(); + $account = Account::factory()->create(['plan_id' => $plan?->id]); + + $service = new PostHogService; + $service->capture('user-123', 'subscription.created', ['stripe_status' => 'active'], $account); + + Queue::assertPushed(SendEvent::class, function ($job) use ($account, $plan) { + $payload = $job->calls[0]['payload']; + + return $payload['event'] === 'subscription.created' + && $payload['properties']['$groups']['account'] === (string) $account->id + && $payload['properties']['account_id'] === (string) $account->id + && $payload['properties']['plan'] === $plan?->name + && $payload['properties']['stripe_status'] === 'active'; + }); +}); + +test('capture without account does not attach group properties', function () { + Queue::fake(); + config(['services.posthog.api_key' => 'phc_test_key']); + + $service = new PostHogService; + $service->capture('user-123', 'page.viewed', ['url' => '/dashboard']); + + Queue::assertPushed(SendEvent::class, function ($job) { + $properties = $job->calls[0]['payload']['properties']; + + return ! array_key_exists('$groups', $properties) + && ! array_key_exists('account_id', $properties) + && ! array_key_exists('plan', $properties); + }); +});