refactor: namespace PostHog jobs + extract billing tracking, add tests
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).
This commit is contained in:
parent
e3538df2f0
commit
3e3c0b4c9d
14 changed files with 536 additions and 140 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
63
app/Jobs/PostHog/TrackBilling.php
Normal file
63
app/Jobs/PostHog/TrackBilling.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\PostHog;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Captures a Stripe billing lifecycle event on the account owner profile
|
||||
* with the `account` group attached, then re-queues `SyncUser` so the
|
||||
* group properties (plan, has_active_subscription, is_on_trial) reflect
|
||||
* the new Stripe state. Dispatched by `StripeEventListener`.
|
||||
*
|
||||
* No-op when `POSTHOG_API_KEY` is unset.
|
||||
*/
|
||||
class TrackBilling implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, mixed> $payload
|
||||
*/
|
||||
protected function handleSubscriptionCreated(Account $account, array $payload): void
|
||||
{
|
||||
SubscriptionCreated::dispatch($account);
|
||||
|
||||
$this->trackBilling($account, 'subscription.created', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function handleSubscriptionUpdated(Account $account, array $payload): void
|
||||
{
|
||||
$this->trackBilling($account, 'subscription.updated', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
139
tests/Feature/Jobs/PostHog/SyncUserTest.php
Normal file
139
tests/Feature/Jobs/PostHog/SyncUserTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\PostHog\SendEvent;
|
||||
use App\Jobs\PostHog\SyncUser;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.posthog.api_key' => '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');
|
||||
});
|
||||
98
tests/Feature/Jobs/PostHog/TrackBillingTest.php
Normal file
98
tests/Feature/Jobs/PostHog/TrackBillingTest.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\PostHog\SendEvent;
|
||||
use App\Jobs\PostHog\SyncUser;
|
||||
use App\Jobs\PostHog\TrackBilling;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.posthog.api_key' => '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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue