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).
139 lines
4.8 KiB
PHP
139 lines
4.8 KiB
PHP
<?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');
|
|
});
|