-
-
+
+
+
-
+
{{ $t('sidebar.no_notifications') }}
diff --git a/resources/js/components/UserMenuContent.vue b/resources/js/components/UserMenuContent.vue
deleted file mode 100644
index 2fc9c54da..000000000
--- a/resources/js/components/UserMenuContent.vue
+++ /dev/null
@@ -1,115 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('sidebar.profile') }}
-
-
-
-
-
- {{ $t('sidebar.settings') }}
-
-
-
-
-
-
-
-
- {{ $t('sidebar.language', { name: currentLanguage?.name ?? 'English' }) }}
-
-
-
-
- {{ language.name }}
-
-
-
-
-
-
-
-
-
- {{ $t('sidebar.log_out') }}
-
-
-
diff --git a/resources/js/components/WorkspaceMenuContent.vue b/resources/js/components/WorkspaceMenuContent.vue
new file mode 100644
index 000000000..1a2a59b4a
--- /dev/null
+++ b/resources/js/components/WorkspaceMenuContent.vue
@@ -0,0 +1,225 @@
+
+
+
+
+ {{ user.name }}
+
+
+
+
+
+
+ {{ $t('sidebar.my_account') }}
+
+
+
+
+
+ {{ $t('sidebar.account_settings') }}
+
+
+
+
+
+ {{ $t('sidebar.settings') }}
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t('sidebar.language', {
+ name: currentLanguage?.name ?? 'English',
+ })
+ }}
+
+
+
+
+ {{ language.name }}
+
+
+
+
+
+
+
+
+
+ {{ $t('sidebar.workspaces') }}
+
+
+
+
+
+ {{ workspace.name }}
+
+
+
+
+ {{ $t('sidebar.create_workspace') }}
+
+
+
+
+
+
+
+
+ {{ $t('sidebar.log_out') }}
+
+
+
diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue
index 0960a3552..ae7d62d2b 100644
--- a/resources/js/components/accounts/NetworkConnectGrid.vue
+++ b/resources/js/components/accounts/NetworkConnectGrid.vue
@@ -34,10 +34,13 @@ const props = withDefaults(
platforms: AvailablePlatform[];
connectedAccounts?: ConnectedAccount[];
gridClass?: string;
+ /** When set, OAuth success reloads only these Inertia props (e.g. onboarding). */
+ reloadOnly?: string[];
}>(),
{
connectedAccounts: () => [],
gridClass: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
+ reloadOnly: undefined,
},
);
@@ -157,7 +160,11 @@ const disconnectModal = ref
| null>(
const { openOAuthPopup } = useOAuthPopup((result) => {
if (result.success) {
toast.success(result.message);
- router.reload();
+ router.reload(
+ props.reloadOnly !== undefined && props.reloadOnly.length > 0
+ ? { only: props.reloadOnly }
+ : undefined,
+ );
return;
}
diff --git a/resources/js/components/onboarding/OnboardingResidualMobile.vue b/resources/js/components/onboarding/OnboardingResidualMobile.vue
new file mode 100644
index 000000000..e4335e99d
--- /dev/null
+++ b/resources/js/components/onboarding/OnboardingResidualMobile.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('sidebar.onboarding') }}
+
+
+ {{ residual.completed }}/{{ residual.total }}
+
+
+
+
+
+
+
diff --git a/resources/js/components/onboarding/OnboardingStepCard.vue b/resources/js/components/onboarding/OnboardingStepCard.vue
new file mode 100644
index 000000000..0c57c8e19
--- /dev/null
+++ b/resources/js/components/onboarding/OnboardingStepCard.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ {{ step }}
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+ {{
+ done
+ ? $t('onboarding.status.complete')
+ : $t('onboarding.status.todo')
+ }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/onboarding/SidebarOnboarding.vue b/resources/js/components/onboarding/SidebarOnboarding.vue
new file mode 100644
index 000000000..f776e5f8a
--- /dev/null
+++ b/resources/js/components/onboarding/SidebarOnboarding.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('sidebar.onboarding') }}
+
+
+ {{ $t('sidebar.onboarding_hint') }}
+
+
+
+
+
+ {{ residual.completed }}/{{ residual.total }}
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/accordion/Accordion.vue b/resources/js/components/ui/accordion/Accordion.vue
new file mode 100644
index 000000000..58ad491e6
--- /dev/null
+++ b/resources/js/components/ui/accordion/Accordion.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/accordion/AccordionContent.vue b/resources/js/components/ui/accordion/AccordionContent.vue
new file mode 100644
index 000000000..91c8dccd3
--- /dev/null
+++ b/resources/js/components/ui/accordion/AccordionContent.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/accordion/AccordionItem.vue b/resources/js/components/ui/accordion/AccordionItem.vue
new file mode 100644
index 000000000..d890a2b11
--- /dev/null
+++ b/resources/js/components/ui/accordion/AccordionItem.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/accordion/AccordionTrigger.vue b/resources/js/components/ui/accordion/AccordionTrigger.vue
new file mode 100644
index 000000000..c20ffba37
--- /dev/null
+++ b/resources/js/components/ui/accordion/AccordionTrigger.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/accordion/index.ts b/resources/js/components/ui/accordion/index.ts
new file mode 100644
index 000000000..b18018b56
--- /dev/null
+++ b/resources/js/components/ui/accordion/index.ts
@@ -0,0 +1,4 @@
+export { default as Accordion } from "./Accordion.vue"
+export { default as AccordionContent } from "./AccordionContent.vue"
+export { default as AccordionItem } from "./AccordionItem.vue"
+export { default as AccordionTrigger } from "./AccordionTrigger.vue"
diff --git a/resources/js/composables/useWorkspaceSettingsTabs.ts b/resources/js/composables/useWorkspaceSettingsTabs.ts
new file mode 100644
index 000000000..c645affa8
--- /dev/null
+++ b/resources/js/composables/useWorkspaceSettingsTabs.ts
@@ -0,0 +1,36 @@
+import { computed } from 'vue';
+import { trans } from 'laravel-vue-i18n';
+
+import { members as membersRoute } from '@/routes/app';
+import { index as apiKeysRoute } from '@/routes/app/api-keys';
+import { index as mcpRoute } from '@/routes/app/mcp';
+import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
+
+export const useWorkspaceSettingsTabs = () =>
+ computed(() => [
+ {
+ name: 'workspace',
+ label: trans('settings.workspace.tabs.workspace'),
+ href: workspaceSettings.url(),
+ },
+ {
+ name: 'brand',
+ label: trans('settings.workspace.tabs.brand'),
+ href: brandRoute.url(),
+ },
+ {
+ name: 'members',
+ label: trans('settings.workspace.tabs.users'),
+ href: membersRoute.url(),
+ },
+ {
+ name: 'api-keys',
+ label: trans('settings.workspace.tabs.api_keys'),
+ href: apiKeysRoute.url(),
+ },
+ {
+ name: 'mcp',
+ label: trans('settings.workspace.tabs.mcp'),
+ href: mcpRoute.url(),
+ },
+ ]);
diff --git a/resources/js/layouts/OnboardingLayout.vue b/resources/js/layouts/OnboardingLayout.vue
deleted file mode 100644
index 2f859c55a..000000000
--- a/resources/js/layouts/OnboardingLayout.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
-
-
-
-
-

-

-
-
-
-
-
-
{{ title }}
-
- {{ description }}
-
-
-
-
-
-
-
-
diff --git a/resources/js/layouts/WelcomeLayout.vue b/resources/js/layouts/WelcomeLayout.vue
new file mode 100644
index 000000000..314e284c3
--- /dev/null
+++ b/resources/js/layouts/WelcomeLayout.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+

+

+
+
+
+
+
+
{{ title }}
+
+ {{ description }}
+
+
+
+
+
+
+
+ {{ $t('auth.verify_email.log_out') }}
+
+
+
+
+
+
diff --git a/resources/js/layouts/app/AppSidebarLayout.vue b/resources/js/layouts/app/AppSidebarLayout.vue
index f0ac4b825..4b4f006ef 100644
--- a/resources/js/layouts/app/AppSidebarLayout.vue
+++ b/resources/js/layouts/app/AppSidebarLayout.vue
@@ -1,16 +1,29 @@
-
+
-
-
+
+
-
+
-
+
-
-
-
+
+
+
-
+
trypost.it · checkout
-
+
-
-
+
+
- Live
+ {{ $t('billing.processing.live') }}
-
-
-
+
+
+
-
+
{{ $t('billing.processing.title') }}
- {{ $t('billing.processing.description') }}
+ {{
+ takingLong
+ ? $t('billing.processing.taking_long')
+ : $t('billing.processing.description')
+ }}
diff --git a/resources/js/pages/onboarding/Connect.vue b/resources/js/pages/onboarding/Connect.vue
deleted file mode 100644
index 80ae728ab..000000000
--- a/resources/js/pages/onboarding/Connect.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('onboarding.connect.title') }}
-
-
- {{ $t('onboarding.connect.description') }}
-
-
-
-
-
-
-
-
- {{ $t('onboarding.connect.must_connect') }}
-
-
-
-
-
diff --git a/resources/js/pages/onboarding/Goals.vue b/resources/js/pages/onboarding/Goals.vue
deleted file mode 100644
index a5e7db0d1..000000000
--- a/resources/js/pages/onboarding/Goals.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('onboarding.goals_title') }}
-
-
- {{ $t('onboarding.goals_description') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/onboarding/Index.vue b/resources/js/pages/onboarding/Index.vue
index 376c9d09c..faf951423 100644
--- a/resources/js/pages/onboarding/Index.vue
+++ b/resources/js/pages/onboarding/Index.vue
@@ -1,127 +1,385 @@
-
+
-
-
-
-
-
+
+
+
+
+ {{ $t('onboarding.welcome', { name: firstName }) }}
+
+
+ {{ $t('onboarding.welcome_anonymous') }}
+
+
+
+ {{ $t('onboarding.description') }}
+
+
+
+
-
-
+ {{ $t('onboarding.social.connected_elsewhere') }}
+
+
+
+
+
+
+
+ {{ $t('onboarding.first_post.prompt_label') }}
+
+
+ {{ samplePrompt }}
+
+
+
+
+
+
+ {{ $t('onboarding.first_post.copy_prompt') }}
+
+
+ {{ $t('onboarding.first_post.or') }}
+
+
+
+ {{ $t('onboarding.first_post.create_button') }}
+
+
+
+
-
+
+
+
+
+
+
+ {{ $t('onboarding.ready.title') }}
+
+
+ {{ $t('onboarding.ready.description') }}
+
+
+
{{ $t('onboarding.continue') }}
-
-
+
-
+
diff --git a/resources/js/pages/onboarding/ReferralSource.vue b/resources/js/pages/onboarding/ReferralSource.vue
deleted file mode 100644
index 76afe58b4..000000000
--- a/resources/js/pages/onboarding/ReferralSource.vue
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('onboarding.referral_source_title') }}
-
-
- {{ $t('onboarding.referral_source_description') }}
-
-
-
-
-
-
-
-
-
- {{ sourceLabel(source) }}
-
-
-
-
-
-
-
-
-
- {{ $t('onboarding.continue') }}
-
-
-
-
-
-
diff --git a/resources/js/pages/settings/workspace/ApiKeys.vue b/resources/js/pages/settings/workspace/ApiKeys.vue
index b12444f20..6e2827a85 100644
--- a/resources/js/pages/settings/workspace/ApiKeys.vue
+++ b/resources/js/pages/settings/workspace/ApiKeys.vue
@@ -30,9 +30,8 @@ import {
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
-import { members as membersRoute } from '@/routes/app';
-import { index as apiKeysRoute } from '@/routes/app/api-keys';
-import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
+import { useWorkspaceSettingsTabs } from '@/composables/useWorkspaceSettingsTabs';
+
interface ApiToken {
id: string;
name: string;
@@ -53,12 +52,7 @@ const newToken = computed(() => (page.props.flash as Record
)?.p
const createDialogOpen = ref(false);
const confirmDeleteModal = ref | null>(null);
-const tabs = computed(() => [
- { name: 'workspace', label: trans('settings.workspace.tabs.workspace'), href: workspaceSettings.url() },
- { name: 'brand', label: trans('settings.workspace.tabs.brand'), href: brandRoute.url() },
- { name: 'members', label: trans('settings.workspace.tabs.users'), href: membersRoute.url() },
- { name: 'api-keys', label: trans('settings.workspace.tabs.api_keys'), href: apiKeysRoute.url() },
-]);
+const tabs = useWorkspaceSettingsTabs();
diff --git a/resources/js/pages/settings/workspace/Brand.vue b/resources/js/pages/settings/workspace/Brand.vue
index 9e0c5808d..b271f67ef 100644
--- a/resources/js/pages/settings/workspace/Brand.vue
+++ b/resources/js/pages/settings/workspace/Brand.vue
@@ -1,15 +1,11 @@
diff --git a/resources/js/pages/settings/workspace/Mcp.vue b/resources/js/pages/settings/workspace/Mcp.vue
new file mode 100644
index 000000000..50c80478e
--- /dev/null
+++ b/resources/js/pages/settings/workspace/Mcp.vue
@@ -0,0 +1,455 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ {{ $t('onboarding.mcp.copy_step') }}
+
+
+
+
+
+
+ {{ mcpUrl }}
+
+
+
+
+ {{ $t('onboarding.mcp.copy') }}
+
+
+
+
+
+
+
+ 2
+
+
+ {{ $t('onboarding.mcp.open_step') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+ {{ $t(client.name) }}
+
+
+ {{ $t(client.description) }}
+
+
+
+
+
+
+
+ {{ $t('mcp.step_add') }}
+
+
+
+
+
+
+ {{ connectorName }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ mcpUrl }}
+
+
+
+
+
+
+
+
+
+
+
{{ configSnippet }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('mcp.connected_empty') }}
+
+
+
+
+
+
+ {{ client.name }}
+
+
+ {{
+ $t('mcp.connected_by', {
+ name: client.user_name,
+ })
+ }}
+
+
+ {{ $t('mcp.last_used') }}:
+ {{
+ client.last_used_at
+ ? date.diffForHumans(
+ client.last_used_at,
+ )
+ : $t('mcp.never')
+ }}
+
+
+
+ {{ $t('mcp.disconnect') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('mcp.view_docs') }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/pages/settings/workspace/Members.vue b/resources/js/pages/settings/workspace/Members.vue
index 0153aeb22..740fc7f81 100644
--- a/resources/js/pages/settings/workspace/Members.vue
+++ b/resources/js/pages/settings/workspace/Members.vue
@@ -1,15 +1,11 @@
diff --git a/resources/js/pages/settings/workspace/Workspace.vue b/resources/js/pages/settings/workspace/Workspace.vue
index 56db8152d..ac4dd770d 100644
--- a/resources/js/pages/settings/workspace/Workspace.vue
+++ b/resources/js/pages/settings/workspace/Workspace.vue
@@ -1,15 +1,11 @@
diff --git a/resources/js/pages/welcome/Goals.vue b/resources/js/pages/welcome/Goals.vue
new file mode 100644
index 000000000..bca259276
--- /dev/null
+++ b/resources/js/pages/welcome/Goals.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ goalLabel(goal) }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('welcome.continue') }}
+
+
+
+
diff --git a/resources/js/pages/welcome/Persona.vue b/resources/js/pages/welcome/Persona.vue
new file mode 100644
index 000000000..4df59a66e
--- /dev/null
+++ b/resources/js/pages/welcome/Persona.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ personaLabel(persona) }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('welcome.continue') }}
+
+
+
+
diff --git a/resources/js/pages/welcome/ReferralSource.vue b/resources/js/pages/welcome/ReferralSource.vue
new file mode 100644
index 000000000..c914496c0
--- /dev/null
+++ b/resources/js/pages/welcome/ReferralSource.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ sourceLabel(source) }}
+
+
+
+
+
+
+
+
+
+ {{ $t('welcome.checkout_owner_only') }}
+
+
+
+
+ {{
+ trialDays > 0
+ ? $t('welcome.checkout_trial_note', {
+ days: trialDays,
+ plan: plan.name,
+ })
+ : $t('welcome.checkout_plan_note', {
+ plan: plan.name,
+ })
+ }}
+
+
+ {{ $t('welcome.continue') }}
+
+
+
+
+
diff --git a/resources/js/pages/welcome/SubscriptionRequired.vue b/resources/js/pages/welcome/SubscriptionRequired.vue
new file mode 100644
index 000000000..82bcde85a
--- /dev/null
+++ b/resources/js/pages/welcome/SubscriptionRequired.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t('welcome.subscription_required_owner', {
+ name: ownerName,
+ })
+ }}
+
+
+
+
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
index 897ce048a..ddc5c336f 100644
--- a/resources/js/types/index.d.ts
+++ b/resources/js/types/index.d.ts
@@ -62,6 +62,11 @@ export interface NavItem {
badge?: string;
}
+export interface OnboardingResidual {
+ completed: number;
+ total: number;
+}
+
export interface ContentTypeMediaRule {
max_files: number;
min_files: number | null;
@@ -84,6 +89,7 @@ export interface SharedData {
name: string;
auth: Auth;
flash: FlashData;
+ onboardingResidual: OnboardingResidual | false;
sidebarOpen: boolean;
selfHosted: boolean;
contentTypeMediaRules?: Record;
@@ -127,4 +133,3 @@ export interface ContentLanguageOption {
label: string;
englishName?: string;
}
-
diff --git a/routes/ai.php b/routes/ai.php
index 074de8a67..ae225a394 100644
--- a/routes/ai.php
+++ b/routes/ai.php
@@ -8,4 +8,5 @@
Mcp::oauthRoutes();
Mcp::web('/mcp/trypost', TryPostServer::class)
- ->middleware(['auth:api', 'workspace.token']);
+ ->middleware(['auth:api', 'workspace.token'])
+ ->name('mcp.trypost');
diff --git a/routes/app.php b/routes/app.php
index 75969ac03..5cdda3f0f 100644
--- a/routes/app.php
+++ b/routes/app.php
@@ -10,6 +10,7 @@
use App\Http\Controllers\App\DiscordController as AppDiscordController;
use App\Http\Controllers\App\GiphyController;
use App\Http\Controllers\App\LinkPreviewController;
+use App\Http\Controllers\App\McpSettingsController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
use App\Http\Controllers\App\PostAiCreateController;
@@ -27,6 +28,7 @@
use App\Http\Controllers\App\Settings\SettingsController;
use App\Http\Controllers\App\Settings\UsageController;
use App\Http\Controllers\App\UnsplashController;
+use App\Http\Controllers\App\WelcomeController;
use App\Http\Controllers\App\WorkspaceController;
use App\Http\Controllers\App\WorkspaceInviteController;
use App\Http\Controllers\App\WorkspaceLabelController;
@@ -57,15 +59,25 @@
})->name('app.home');
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
- Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
- Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store');
- Route::get('onboarding/goals', [OnboardingController::class, 'goals'])->name('app.onboarding.goals');
- Route::post('onboarding/goals', [OnboardingController::class, 'storeGoals'])->name('app.onboarding.goals.store');
- Route::get('onboarding/referral-source', [OnboardingController::class, 'referralSource'])->name('app.onboarding.referral-source');
- Route::post('onboarding/referral-source', [OnboardingController::class, 'storeReferralSource'])->name('app.onboarding.referral-source.store');
- Route::get('onboarding/connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect');
- Route::post('onboarding/connect', [OnboardingController::class, 'checkout'])->name('app.onboarding.checkout');
- Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
+ Route::get('welcome', fn () => redirect()->route('app.welcome.persona'))->name('app.welcome');
+ Route::get('welcome/persona', [WelcomeController::class, 'persona'])->name('app.welcome.persona');
+ Route::post('welcome/persona', [WelcomeController::class, 'storePersona'])->name('app.welcome.persona.store');
+ Route::get('welcome/goals', [WelcomeController::class, 'goals'])->name('app.welcome.goals');
+ Route::post('welcome/goals', [WelcomeController::class, 'storeGoals'])->name('app.welcome.goals.store');
+ Route::get('welcome/referral-source', [WelcomeController::class, 'referralSource'])->name('app.welcome.referral-source');
+ Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required');
+ Route::post('welcome/referral-source', [WelcomeController::class, 'storeReferralSource'])
+ ->middleware('throttle:6,1')
+ ->name('app.welcome.referral-source.store');
+ Route::get('onboarding/goals', fn () => redirect()->route('app.welcome.goals'))
+ ->name('app.legacy-onboarding.goals');
+ Route::get('onboarding/referral-source', fn () => redirect()->route('app.welcome.referral-source'))
+ ->name('app.legacy-onboarding.referral-source');
+ Route::get('onboarding/connect', fn () => redirect()->route('app.welcome.referral-source'))
+ ->name('app.legacy-onboarding.connect');
+ Route::get('billing/processing', [BillingController::class, 'processing'])
+ ->middleware('throttle:60,1')
+ ->name('app.billing.processing');
Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');
Route::post('workspaces', [WorkspaceController::class, 'store'])->name('app.workspaces.store');
@@ -83,11 +95,10 @@
// Social Connect routes
Route::middleware(['auth'])->group(function () {
- // Starting a connection reads the user's current workspace, so these require
- // one — during onboarding they redirect to workspace creation. Disconnecting
- // lives here too (and not behind EnsureAccountReady) so it works before a
- // subscription exists; the controller still authorizes workspace ownership.
- Route::middleware(EnsureHasWorkspace::class)->group(function () {
+ // Starting a connection requires app access (post-subscription onboarding /
+ // paid accounts). Disconnect stays reachable without a subscription so
+ // cleanup still works; the controller still authorizes workspace ownership.
+ Route::middleware([EnsureAccountReady::class, EnsureHasWorkspace::class])->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::get('connect/x', [XController::class, 'connect'])->name('app.social.x.connect');
Route::get('connect/tiktok', [TikTokController::class, 'connect'])->name('app.social.tiktok.connect');
@@ -103,7 +114,9 @@
Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize');
Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect');
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
+ });
+ Route::middleware(EnsureHasWorkspace::class)->group(function () {
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
});
@@ -144,8 +157,12 @@
Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback');
});
-// Routes that require active subscription and completed onboarding
+// Routes that require account access and a current workspace
Route::middleware(['auth', EnsureAccountReady::class, EnsureHasWorkspace::class])->group(function () {
+ Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
+ Route::post('onboarding/dismiss', [OnboardingController::class, 'dismiss'])->name('app.onboarding.dismiss');
+ Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('app.onboarding.complete');
+
// Discord — live lookups for the composer (channel picker + mention autocomplete).
// Throttled because they proxy the shared bot's (rate-limited) Discord API.
Route::get('discord/accounts/{account}/channels', [AppDiscordController::class, 'channels'])
@@ -265,6 +282,10 @@
Route::post('settings/workspace/api-keys', [ApiKeyController::class, 'store'])->name('app.api-keys.store');
Route::delete('settings/workspace/api-keys/{tokenId}', [ApiKeyController::class, 'destroy'])->name('app.api-keys.destroy');
+ // MCP
+ Route::get('settings/workspace/mcp', [McpSettingsController::class, 'index'])->name('app.mcp.index');
+ Route::delete('settings/workspace/mcp/{client}', [McpSettingsController::class, 'disconnect'])->name('app.mcp.disconnect');
+
// Account Settings
Route::get('settings/account', [AccountController::class, 'edit'])->name('app.account.edit');
Route::put('settings/account', [AccountController::class, 'update'])->name('app.account.update');
diff --git a/tests/Browser/WelcomeOnboardingTest.php b/tests/Browser/WelcomeOnboardingTest.php
new file mode 100644
index 000000000..9b701c4a5
--- /dev/null
+++ b/tests/Browser/WelcomeOnboardingTest.php
@@ -0,0 +1,169 @@
+script(<< {
+ const sel = '[data-testid="{$selector}"]';
+ for (let i = 0; i < 100; i++) {
+ if (document.querySelector(sel)) return true;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ return false;
+ })();
+ JS);
+
+ expect($found)->toBeTrue("Timed out waiting for [data-testid=\"{$selector}\"] to appear.");
+}
+
+/**
+ * Poll browser-side until the data-testid element is gone — and fail loudly
+ * when it never disappears.
+ */
+function waitForDuskGone(mixed $page, string $selector): void
+{
+ $gone = $page->script(<< {
+ const sel = '[data-testid="{$selector}"]';
+ for (let i = 0; i < 100; i++) {
+ if (! document.querySelector(sel)) return true;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ return false;
+ })();
+ JS);
+
+ expect($gone)->toBeTrue("Timed out waiting for [data-testid=\"{$selector}\"] to disappear.");
+}
+
+/**
+ * Poll browser-side until the location path matches, then let the new page
+ * mount before asserting.
+ */
+function waitForPath(mixed $page, string $path): void
+{
+ $reached = $page->script(<< {
+ for (let i = 0; i < 100; i++) {
+ if (window.location.pathname === '{$path}') break;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ await new Promise((r) => setTimeout(r, 500));
+ return window.location.pathname === '{$path}';
+ })();
+ JS);
+
+ expect($reached)->toBeTrue("Timed out waiting for path {$path}.");
+}
+
+test('owner walks the welcome steps up to the checkout CTA', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+
+ // fresh(): the in-process server keeps the guard user across requests, and
+ // the factory instance lacks the nullable ICP columns (strict models throw).
+ test()->actingAs($user->fresh());
+
+ $page = visit(route('app.welcome.persona'));
+
+ waitForDusk($page, 'welcome-persona-creator');
+ $page->assertVisible('@welcome-persona-creator')
+ ->click('@welcome-persona-creator')
+ ->click('@welcome-persona-continue');
+
+ waitForDusk($page, 'welcome-goal-save_time');
+ $page->assertVisible('@welcome-goal-save_time')
+ ->click('@welcome-goal-save_time')
+ ->click('@welcome-goals-continue');
+
+ // The CTA shows the plan/trial context and stays disabled until a source
+ // is picked. Clicking through to Stripe is covered by feature tests — the
+ // browser must not hit the Stripe API.
+ waitForDusk($page, 'welcome-start-checkout');
+ $page->assertVisible('@welcome-checkout-plan-note')
+ ->click('@welcome-source-google');
+
+ $checkoutEnabled = $page->script('(() => { const b = document.querySelector(\'[data-testid="welcome-start-checkout"]\'); return b !== null && ! b.disabled; })()');
+
+ expect($checkoutEnabled)->toBeTrue();
+
+ expect($user->fresh()->persona?->value)->toBe('creator')
+ ->and($user->fresh()->goals)->toBe(['save_time']);
+});
+
+test('owner skips the onboarding checklist and the residual banner disappears', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+ subscribeAccount($user->account);
+
+ test()->actingAs($user->fresh());
+
+ $page = visit(route('app.onboarding'));
+
+ waitForDusk($page, 'onboarding-skip');
+ $page->assertVisible('@onboarding-mcp')
+ ->assertVisible('@onboarding-social')
+ ->assertVisible('@onboarding-first-post')
+ ->click('@onboarding-skip');
+
+ // Skip lands on the calendar and the sidebar residual is gone for good.
+ waitForPath($page, parse_url(route('app.calendar'), PHP_URL_PATH));
+ $page->assertMissing('@sidebar-onboarding');
+
+ expect($user->account->fresh()->onboarding_dismissed_at)->not->toBeNull();
+});
+
+test('member without app access lands on the subscription required screen', function () {
+ config(['trypost.self_hosted' => false]);
+
+ ['member' => $member] = strandedMemberOnSharedAccount();
+
+ test()->actingAs($member);
+
+ $page = visit(route('app.calendar'));
+
+ waitForDusk($page, 'welcome-subscription-required');
+ $page->assertVisible('@welcome-subscription-required');
+});
+
+test('owner sees the residual banner on mobile and can dismiss it in place', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+ subscribeAccount($user->account);
+
+ test()->actingAs($user->fresh());
+
+ $page = visit(route('app.calendar'))->resize(375, 812);
+
+ waitForDusk($page, 'sidebar-onboarding-mobile');
+ $page->assertVisible('@sidebar-onboarding-mobile')
+ ->click('@sidebar-onboarding-mobile-dismiss');
+
+ // Dismiss with `stay` keeps the user on the calendar and clears the banner.
+ waitForDuskGone($page, 'sidebar-onboarding-mobile');
+ $page->assertMissing('@sidebar-onboarding-mobile');
+
+ expect($user->account->fresh()->onboarding_dismissed_at)->not->toBeNull();
+});
diff --git a/tests/Feature/Actions/Onboarding/ResolveOnboardingStatusTest.php b/tests/Feature/Actions/Onboarding/ResolveOnboardingStatusTest.php
new file mode 100644
index 000000000..3a1ae4d66
--- /dev/null
+++ b/tests/Feature/Actions/Onboarding/ResolveOnboardingStatusTest.php
@@ -0,0 +1,548 @@
+ false]);
+
+ $this->user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+ $this->user->refresh();
+
+ subscribeAccount($this->user->account);
+});
+
+test('resolves the empty onboarding state', function () {
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status)->toBe([
+ 'mcp_connected' => false,
+ 'social_connected' => false,
+ 'first_post_created' => false,
+ 'all_complete' => false,
+ 'show_residual' => true,
+ 'completed_at' => null,
+ 'dismissed_at' => null,
+ ]);
+});
+
+test('resolves an OAuth token as MCP connected', function () {
+ mcpAccessToken($this->user, mcpOauthClient());
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status)->toMatchArray([
+ 'mcp_connected' => true,
+ 'social_connected' => false,
+ 'first_post_created' => false,
+ 'all_complete' => false,
+ ]);
+});
+
+test('does not resolve a personal access token as MCP connected', function () {
+ $this->user->createToken('Personal Access Token');
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['mcp_connected'])->toBeFalse();
+});
+
+test('does not resolve a workspace-bound personal access token as MCP connected', function () {
+ $result = $this->user->createToken('Personal Access Token');
+ AccessToken::find($result->token->id)
+ ->forceFill(['workspace_id' => $this->workspace->id])
+ ->saveQuietly();
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['mcp_connected'])->toBeFalse();
+});
+
+test('does not resolve a revoked token as MCP connected', function () {
+ $token = mcpAccessToken($this->user, mcpOauthClient());
+ $token->forceFill(['revoked' => true])->saveQuietly();
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['mcp_connected'])->toBeFalse();
+});
+
+test('resolves a social account in the current workspace as connected', function () {
+ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status)->toMatchArray([
+ 'mcp_connected' => false,
+ 'social_connected' => true,
+ 'first_post_created' => false,
+ 'all_complete' => false,
+ ]);
+});
+
+test('captures each completed step once without re-firing later', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Bus::fake();
+
+ $cacheKey = "onboarding_step:{$this->user->account_id}:social_connected";
+ Cache::forget($cacheKey);
+ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
+
+ app(ResolveOnboardingStatus::class)->syncProgress($this->user);
+ app(ResolveOnboardingStatus::class)->syncProgress($this->user);
+
+ Bus::assertDispatchedTimes(SendEvent::class, 1);
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'event') === OnboardingEvent::StepCompleted->value
+ && data_get($event->payload, 'properties.step') === 'social_connected');
+
+ Carbon::setTestNow(now()->addDays(31));
+ app(ResolveOnboardingStatus::class)->syncProgress($this->user);
+
+ Bus::assertDispatchedTimes(SendEvent::class, 1);
+});
+
+test('does not resolve an expired oauth token as mcp connected', function () {
+ $token = AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly();
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['mcp_connected'])->toBeFalse();
+});
+
+test('resolves account-scoped steps even when the user has no current workspace', function () {
+ $this->user->update(['current_workspace_id' => null]);
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh());
+
+ expect($status)->toMatchArray([
+ 'mcp_connected' => true,
+ 'social_connected' => true,
+ 'first_post_created' => true,
+ 'all_complete' => true,
+ ]);
+});
+
+test('members do not see the residual checklist', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($member->fresh()))->toBeFalse()
+ ->and(app(ResolveOnboardingStatus::class)->handle($member->fresh())['show_residual'])->toBeFalse();
+});
+
+test('generic trial without card still shows residual for the owner', function () {
+ config(['trypost.billing.require_card_for_trial' => false]);
+ $this->user->account->subscriptions()->delete();
+ $this->user->account->update(['trial_ends_at' => now()->addDays(7)]);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($this->user->fresh()))->toBe([
+ 'completed' => 0,
+ 'total' => ResolveOnboardingStatus::TOTAL_STEPS,
+ ]);
+});
+
+test('resolves a social account in another workspace as connected', function () {
+ $otherWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]);
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['social_connected'])->toBeTrue();
+});
+
+test('resolves any post in the current workspace as the first post', function () {
+ Post::factory()->failed()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]);
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status)->toMatchArray([
+ 'mcp_connected' => false,
+ 'social_connected' => false,
+ 'first_post_created' => true,
+ 'all_complete' => false,
+ ]);
+});
+
+test('resolves a post in another workspace as the first post', function () {
+ $otherWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ Post::factory()->create([
+ 'workspace_id' => $otherWorkspace->id,
+ 'user_id' => $this->user->id,
+ ]);
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['first_post_created'])->toBeTrue();
+});
+
+test('marks onboarding completed once all three steps are complete', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+
+ mcpAccessToken($this->user, mcpOauthClient());
+ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
+ Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]);
+
+ $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user);
+
+ expect($status)->toBe([
+ 'mcp_connected' => true,
+ 'social_connected' => true,
+ 'first_post_created' => true,
+ 'all_complete' => true,
+ 'show_residual' => false,
+ 'completed_at' => now()->toIso8601String(),
+ 'dismissed_at' => null,
+ ])->and($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+
+ Carbon::setTestNow(now()->addHour());
+ app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh());
+
+ expect($this->user->account->fresh()->onboarding_completed_at?->toIso8601String())
+ ->toBe('2026-07-24T12:00:00+00:00');
+});
+
+test('handle does not mutate the account when every step is complete', function () {
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status)->toMatchArray([
+ 'all_complete' => true,
+ 'show_residual' => false,
+ 'completed_at' => null,
+ ])->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull();
+});
+
+test('stamps completion when the last step completes off the onboarding page', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Bus::fake();
+
+ mcpAccessToken($this->user, mcpOauthClient());
+ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
+
+ expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull();
+
+ Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]);
+
+ expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value);
+});
+
+test('resolves a teammate oauth token as mcp connected for the account', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ AccessToken::withoutEvents(fn () => mcpAccessToken($member, mcpOauthClient()));
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['mcp_connected'])->toBeTrue();
+});
+
+test('dismissed onboarding does not show the residual checklist', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh());
+
+ expect($status['show_residual'])->toBeFalse()
+ ->and($status['dismissed_at'])->toBe(now()->toIso8601String());
+});
+
+test('completed onboarding returns immediately without resolving steps or capturing analytics', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Bus::fake();
+ $this->user->account->update(['onboarding_completed_at' => now()]);
+
+ $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh());
+
+ expect($status)->toBe([
+ 'mcp_connected' => true,
+ 'social_connected' => true,
+ 'first_post_created' => true,
+ 'all_complete' => true,
+ 'show_residual' => false,
+ 'completed_at' => now()->toIso8601String(),
+ 'dismissed_at' => null,
+ ]);
+ Bus::assertNothingDispatched();
+});
+
+test('self-hosted onboarding does not show the residual checklist', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['show_residual'])->toBeFalse();
+});
+
+test('unsubscribed account does not show the residual checklist', function () {
+ $this->user->account->subscriptions()->delete();
+
+ $status = app(ResolveOnboardingStatus::class)->handle($this->user);
+
+ expect($status['show_residual'])->toBeFalse();
+});
+
+test('residual returns progress counts while onboarding is active', function () {
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($this->user))->toBe([
+ 'completed' => 1,
+ 'total' => ResolveOnboardingStatus::TOTAL_STEPS,
+ ]);
+});
+
+test('checklist and residual both count social and posts from any workspace', function () {
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+
+ $otherWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $otherWorkspace->id,
+ ]));
+
+ $emptyWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->user->update(['current_workspace_id' => $emptyWorkspace->id]);
+
+ expect(app(ResolveOnboardingStatus::class)->handle($this->user->fresh()))->toMatchArray([
+ 'mcp_connected' => true,
+ 'social_connected' => true,
+ 'first_post_created' => false,
+ 'all_complete' => false,
+ 'show_residual' => true,
+ ])->and(app(ResolveOnboardingStatus::class)->residual($this->user->fresh()))->toBe([
+ 'completed' => 2,
+ 'total' => ResolveOnboardingStatus::TOTAL_STEPS,
+ ]);
+});
+
+test('residual returns false when the banner should not show', function () {
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($this->user->fresh()))->toBeFalse();
+});
+
+test('syncProgress stamps when a teammate is on an empty workspace but account activation is done', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $memberWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $member->id,
+ ]);
+ $member->update(['current_workspace_id' => $memberWorkspace->id]);
+
+ expect(app(ResolveOnboardingStatus::class)->syncProgress($member->fresh())['all_complete'])->toBeFalse();
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($member, mcpOauthClient()));
+
+ expect(app(ResolveOnboardingStatus::class)->syncProgress($member->fresh())['all_complete'])->toBeTrue()
+ ->and($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('mcp connection attributes step analytics to the acting teammate', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Bus::fake();
+ Cache::flush();
+
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+
+ $member = User::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'current_workspace_id' => $this->workspace->id,
+ ]);
+
+ // Owner has a lower id and would win if we still fan-out by users.id asc.
+ expect($this->user->id < $member->id)->toBeTrue();
+
+ mcpAccessToken($member, mcpOauthClient());
+
+ Bus::assertDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'event') === OnboardingEvent::StepCompleted->value
+ && data_get($event->payload, 'properties.step') === 'mcp_connected'
+ && data_get($event->payload, 'distinctId') === (string) $member->id,
+ );
+});
+
+test('markCompleted leaves the in-memory account clean', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ expect(app(ResolveOnboardingStatus::class)->markCompleted($this->user))->toBeTrue()
+ ->and($this->user->account->isDirty())->toBeFalse()
+ ->and($this->user->account->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id,
+ );
+});
+
+test('markCompleted refuses to stamp after onboarding was dismissed', function () {
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
+
+ expect(app(ResolveOnboardingStatus::class)->markCompleted($this->user->fresh()))->toBeFalse()
+ ->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull();
+});
+
+test('syncProgress stamps when another workspace already finished social and post', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+
+ $emptyWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->user->update(['current_workspace_id' => $emptyWorkspace->id]);
+
+ $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh());
+
+ expect($status['all_complete'])->toBeTrue()
+ ->and($status['show_residual'])->toBeFalse()
+ ->and($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('residual hides without writing when another workspace already finished activation', function () {
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+
+ $emptyWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->user->update(['current_workspace_id' => $emptyWorkspace->id]);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($this->user->fresh()))->toBeFalse()
+ ->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull();
+});
+
+test('residual does not query step state for dismissed accounts', function () {
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
+
+ $queries = [];
+ DB::listen(fn ($query) => $queries[] = $query->sql);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($this->user->fresh()))->toBeFalse();
+
+ expect(collect($queries)->filter(fn (string $sql): bool => str_contains($sql, 'oauth_access_tokens')
+ || str_contains($sql, 'social_accounts')
+ || str_contains($sql, 'posts')))->toBeEmpty();
+});
+
+test('residual does not query step state for members', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
+
+ $queries = [];
+ DB::listen(fn ($query) => $queries[] = $query->sql);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($member->fresh()))->toBeFalse();
+
+ expect(collect($queries)->filter(fn (string $sql): bool => str_contains($sql, 'oauth_access_tokens')
+ || str_contains($sql, 'social_accounts')
+ || str_contains($sql, 'posts')))->toBeEmpty();
+});
+
+test('residual does not query step state in self hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $queries = [];
+ DB::listen(fn ($query) => $queries[] = $query->sql);
+
+ expect(app(ResolveOnboardingStatus::class)->residual($this->user->fresh()))->toBeFalse();
+
+ expect(collect($queries)->filter(fn (string $sql): bool => str_contains($sql, 'oauth_access_tokens')
+ || str_contains($sql, 'social_accounts')
+ || str_contains($sql, 'posts')))->toBeEmpty();
+});
diff --git a/tests/Feature/Api/LoadWorkspaceFromTokenTest.php b/tests/Feature/Api/LoadWorkspaceFromTokenTest.php
new file mode 100644
index 000000000..d6eb87e97
--- /dev/null
+++ b/tests/Feature/Api/LoadWorkspaceFromTokenTest.php
@@ -0,0 +1,48 @@
+ false,
+ 'trypost.billing.require_card_for_trial' => true,
+ ]);
+
+ $result = createApiTestToken();
+ $this->user = $result['user'];
+ $this->workspace = $result['workspace'];
+ $this->plainToken = $result['plain_token'];
+});
+
+test('rejects api requests when the account has no app access', function () {
+ $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
+ ->getJson(route('api.workspace.show'))
+ ->assertStatus(Response::HTTP_PAYMENT_REQUIRED)
+ ->assertJson(['message' => 'Active subscription required.']);
+});
+
+test('allows api requests for subscribed accounts', function () {
+ subscribeAccount($this->user->account);
+
+ $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
+ ->getJson(route('api.workspace.show'))
+ ->assertOk();
+});
+
+test('allows api requests for generic-trial accounts with app access', function () {
+ config(['trypost.billing.require_card_for_trial' => false]);
+
+ $this->user->account->update([
+ 'trial_ends_at' => now()->addDays(8),
+ ]);
+
+ expect($this->user->account->fresh()->hasAppAccess())->toBeTrue()
+ ->and($this->user->account->fresh()->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse();
+
+ $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
+ ->getJson(route('api.workspace.show'))
+ ->assertOk();
+});
diff --git a/tests/Feature/Billing/TrialLengthTest.php b/tests/Feature/Billing/TrialLengthTest.php
index cbd3a0fdc..25789b80c 100644
--- a/tests/Feature/Billing/TrialLengthTest.php
+++ b/tests/Feature/Billing/TrialLengthTest.php
@@ -8,6 +8,10 @@
expect(config('cashier.trial_days'))->toBe(8);
});
+test('promotion codes are allowed by default', function () {
+ expect(config('cashier.allow_promotion_codes'))->toBeTrue();
+});
+
test('signup grants a trial of cashier.trial_days in no-card mode', function () {
config(['trypost.billing.require_card_for_trial' => false]);
@@ -20,3 +24,16 @@
expect($user->account->trial_ends_at->toDateString())
->toBe(now()->addDays(config('cashier.trial_days'))->toDateString());
});
+
+test('no-card signup refuses CASHIER_TRIAL_DAYS=0 so accounts are not locked out', function () {
+ config([
+ 'trypost.billing.require_card_for_trial' => false,
+ 'cashier.trial_days' => 0,
+ ]);
+
+ expect(fn () => CreateUser::execute([
+ 'name' => 'Locked Out',
+ 'email' => 'locked@example.com',
+ 'password' => 'secret123',
+ ]))->toThrow(RuntimeException::class);
+});
diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php
index d3cea0ed9..6e770fe3d 100644
--- a/tests/Feature/BillingControllerTest.php
+++ b/tests/Feature/BillingControllerTest.php
@@ -7,6 +7,8 @@
use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
+use Stripe\ApiRequestor;
+use Stripe\HttpClient\CurlClient;
beforeEach(function () {
config(['trypost.billing.require_card_for_trial' => true]);
@@ -24,6 +26,11 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
+afterEach(function () {
+ // The Stripe HTTP fake is a process-global static — never leak it.
+ ApiRequestor::setHttpClient(new CurlClient);
+});
+
// Subscribe tests
test('subscribe requires authentication', function () {
$response = $this->get(route('app.subscribe'));
@@ -31,12 +38,12 @@
$response->assertRedirect(route('login'));
});
-test('subscribe redirects to onboarding', function () {
+test('subscribe redirects to welcome', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
- $response->assertRedirect(route('app.onboarding'));
+ $response->assertRedirect(route('app.welcome.persona'));
});
test('swapToYearly redirects to calendar in self hosted mode', function () {
@@ -149,25 +156,149 @@
->component('billing/Processing', false)
->has('subscriptionActive')
->where('fromCheckout', false)
+ ->where('redirectToOnboarding', true)
->where('conversion', null)
);
});
+test('billing processing skips onboarding when already completed', function () {
+ config(['trypost.self_hosted' => false]);
+ $this->user->account->update(['onboarding_completed_at' => now()]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('redirectToOnboarding', false));
+});
+
+test('billing processing skips onboarding when dismissed', function () {
+ config(['trypost.self_hosted' => false]);
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('redirectToOnboarding', false));
+});
+
test('billing processing exposes fromCheckout=true only the first time a session_id is seen', function () {
config(['trypost.self_hosted' => false]);
$sessionId = 'cs_test_'.fake()->uuid();
-
- $first = $this->actingAs($this->user)
+ $this->account->forceFill(['stripe_id' => 'cus_test_123'])->save();
+ fakeStripeHttp([[
+ 'body' => [
+ 'id' => $sessionId,
+ 'customer' => 'cus_test_123',
+ 'amount_total' => 1000,
+ 'currency' => 'usd',
+ ],
+ 'status' => 200,
+ ]]);
+
+ $first = $this->actingAs($this->user->fresh())
->get(route('app.billing.processing', ['session_id' => $sessionId]));
$first->assertOk();
- $first->assertInertia(fn ($page) => $page->where('fromCheckout', true));
+ $first->assertInertia(fn ($page) => $page
+ ->where('fromCheckout', true)
+ ->where('conversion.value', 10)
+ ->where('conversion.transaction_id', $sessionId)
+ );
// A back-button / refresh to the same success URL must not re-fire the event.
- $second = $this->actingAs($this->user)
+ $second = $this->actingAs($this->user->fresh())
->get(route('app.billing.processing', ['session_id' => $sessionId]));
$second->assertOk();
- $second->assertInertia(fn ($page) => $page->where('fromCheckout', false));
+ $second->assertInertia(fn ($page) => $page
+ ->where('fromCheckout', false)
+ ->where('conversion', null)
+ );
+});
+
+test('billing processing does not convert a session owned by another customer', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $sessionId = 'cs_test_'.fake()->uuid();
+ $this->account->forceFill(['stripe_id' => 'cus_test_123'])->save();
+ $stripe = fakeStripeHttp([[
+ 'body' => [
+ 'id' => $sessionId,
+ 'customer' => 'cus_someone_else',
+ 'amount_total' => 1000,
+ 'currency' => 'usd',
+ ],
+ 'status' => 200,
+ ]]);
+
+ // First sight marks fromCheckout, but the customer mismatch yields no
+ // conversion — and the purchase pixel gates on conversion.
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('conversion', null));
+
+ // The mismatch is consumed once — the poll loop must not re-hit Stripe.
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk();
+
+ expect($stripe->calls)->toBe(1);
+});
+
+test('billing processing consumes an unknown session id without retrying stripe', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $sessionId = 'cs_test_'.fake()->uuid();
+ $this->account->forceFill(['stripe_id' => 'cus_test_123'])->save();
+ $stripe = fakeStripeHttp([[
+ 'body' => [
+ 'error' => [
+ 'type' => 'invalid_request_error',
+ 'code' => 'resource_missing',
+ 'message' => 'No such checkout.session',
+ ],
+ ],
+ 'status' => 404,
+ ]]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('conversion', null));
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk();
+
+ expect($stripe->calls)->toBe(1);
+});
+
+test('billing processing skips the purchase conversion when stripe verification fails', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $sessionId = 'cs_test_'.fake()->uuid();
+ $this->account->forceFill(['stripe_id' => 'cus_test_123'])->save();
+ $stripe = fakeStripeHttp([[
+ 'body' => ['error' => ['type' => 'api_error', 'message' => 'boom']],
+ 'status' => 500,
+ ]]);
+
+ // Verified-or-nothing: a transient Stripe failure loses the pixel rather
+ // than firing it unverified. The session is still consumed once.
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('fromCheckout', true)
+ ->where('conversion', null)
+ );
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('fromCheckout', false));
+
+ expect($stripe->calls)->toBe(1);
});
test('billing processing exposes null conversion when session_id query param is missing', function () {
@@ -216,6 +347,48 @@
$response->assertRedirect(route('app.calendar'));
});
+test('billing processing does not let another account burn the buyers checkout session', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $sessionId = 'cs_test_'.fake()->uuid();
+ $this->account->forceFill(['stripe_id' => 'cus_test_123'])->save();
+ fakeStripeHttp([[
+ 'body' => [
+ 'id' => $sessionId,
+ 'customer' => 'cus_test_123',
+ 'amount_total' => 1000,
+ 'currency' => 'usd',
+ ],
+ 'status' => 200,
+ ]]);
+
+ // Another account presenting the buyer's session_id gets nothing — and,
+ // without a stripe_id of its own, never even calls Stripe.
+ $otherUser = User::factory()->create();
+
+ $this->actingAs($otherUser)
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('fromCheckout', false));
+
+ // The real buyer's first visit still counts as their checkout arrival.
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.billing.processing', ['session_id' => $sessionId]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('fromCheckout', true));
+});
+
+test('billing processing does not send members to onboarding', function () {
+ config(['trypost.self_hosted' => false]);
+
+ ['member' => $member] = strandedMemberOnSharedAccount();
+
+ $this->actingAs($member)
+ ->get(route('app.billing.processing'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('redirectToOnboarding', false));
+});
+
// Checkout tests
// Portal tests
test('portal requires authentication', function () {
diff --git a/tests/Feature/Jobs/PostHog/TrackPostTest.php b/tests/Feature/Jobs/PostHog/TrackPostTest.php
index 350042a8e..16c4fdf58 100644
--- a/tests/Feature/Jobs/PostHog/TrackPostTest.php
+++ b/tests/Feature/Jobs/PostHog/TrackPostTest.php
@@ -67,7 +67,8 @@
Queue::assertPushed(
SendEvent::class,
- fn ($job) => $job->payload['distinctId'] === (string) $this->user->id
+ fn ($job) => $job->payload['event'] === PostEvent::Created->value
+ && $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['created_via'] === CreatedVia::Mcp->value,
);
});
diff --git a/tests/Feature/McpSettingsControllerTest.php b/tests/Feature/McpSettingsControllerTest.php
new file mode 100644
index 000000000..d5b755b50
--- /dev/null
+++ b/tests/Feature/McpSettingsControllerTest.php
@@ -0,0 +1,190 @@
+ false]);
+
+ $this->user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+ $this->user->refresh();
+
+ // SaaS mode: the MCP settings live behind EnsureAccountReady.
+ subscribeAccount($this->user->account);
+});
+
+it('shows the mcp settings page', function (): void {
+ $this->actingAs($this->user)
+ ->get(route('app.mcp.index'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('settings/workspace/Mcp')
+ ->where('mcpUrl', route('mcp.trypost'))
+ ->missing('docsUrl')
+ ->missing('mcpClients')
+ ->has('connectedClients'));
+});
+
+it('lists oauth clients as connected across the account, excluding personal access tokens', function (): void {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+
+ $ownerClientId = mcpOauthClient('Owner Agent');
+ mcpAccessToken($this->user, $ownerClientId);
+
+ $memberClientId = mcpOauthClient('Member Agent');
+ mcpAccessToken($member, $memberClientId);
+
+ $pat = $this->user->createToken('API Key');
+ AccessToken::query()->findOrFail($pat->token->id)
+ ->forceFill(['workspace_id' => $this->workspace->id])
+ ->saveQuietly();
+
+ $this->actingAs($this->user)
+ ->get(route('app.mcp.index'))
+ ->assertInertia(fn ($page) => $page
+ ->has('connectedClients', 2)
+ ->where('connectedClients', fn ($clients): bool => collect($clients)->contains(
+ fn (array $client): bool => $client['name'] === 'Owner Agent'
+ && $client['can_disconnect'] === true
+ && $client['user_id'] === (string) $this->user->id,
+ ) && collect($clients)->contains(
+ fn (array $client): bool => $client['name'] === 'Member Agent'
+ && $client['can_disconnect'] === false
+ && $client['user_name'] === $member->name,
+ )));
+});
+
+it('disconnects a client by revoking its tokens and broadcasting onboarding status', function (): void {
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $clientId = mcpOauthClient();
+ $token = AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, $clientId));
+
+ $this->actingAs($this->user)
+ ->delete(route('app.mcp.disconnect', ['client' => $clientId]))
+ ->assertRedirect()
+ ->assertSessionHas('flash.success', __('mcp.disconnected'));
+
+ expect($token->fresh()->revoked)->toBeTrue();
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id,
+ );
+});
+
+it('does not flash success or broadcast when disconnecting an unknown client', function (): void {
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $this->actingAs($this->user)
+ ->delete(route('app.mcp.disconnect', ['client' => (string) Str::uuid()]))
+ ->assertRedirect()
+ ->assertSessionMissing('flash.success');
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+it('lists a teammates connection but does not allow disconnecting it', function (): void {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
+
+ $clientId = mcpOauthClient('Owner Agent');
+ mcpAccessToken($this->user, $clientId);
+
+ $this->actingAs($member->fresh())
+ ->get(route('app.mcp.index'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('connectedClients', 1)
+ ->where('connectedClients.0.name', 'Owner Agent')
+ ->where('connectedClients.0.can_disconnect', false));
+});
+
+it('allows workspace members to view and disconnect their own mcp clients', function (): void {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
+
+ $clientId = mcpOauthClient('Member Agent');
+ $token = mcpAccessToken($member, $clientId);
+
+ $this->actingAs($member->fresh())
+ ->get(route('app.mcp.index'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('connectedClients', 1)
+ ->where('connectedClients.0.name', 'Member Agent'));
+
+ $this->actingAs($member->fresh())
+ ->delete(route('app.mcp.disconnect', ['client' => $clientId]))
+ ->assertRedirect()
+ ->assertSessionHas('flash.success');
+
+ expect($token->fresh()->revoked)->toBeTrue();
+});
+
+it('does not revoke another users mcp client tokens', function (): void {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
+
+ $clientId = mcpOauthClient('Owner Agent');
+ $token = mcpAccessToken($this->user, $clientId);
+
+ $this->actingAs($member->fresh())
+ ->delete(route('app.mcp.disconnect', ['client' => $clientId]))
+ ->assertRedirect()
+ ->assertSessionMissing('flash.success');
+
+ expect($token->fresh()->revoked)->toBeFalse();
+});
+
+it('does not revoke personal access tokens via disconnect', function (): void {
+ $pat = $this->user->createToken('API Key');
+ $token = AccessToken::query()->findOrFail($pat->token->id);
+ $token->forceFill(['workspace_id' => $this->workspace->id])->saveQuietly();
+
+ $this->actingAs($this->user)
+ ->delete(route('app.mcp.disconnect', ['client' => $token->client_id]))
+ ->assertRedirect()
+ ->assertSessionMissing('flash.success');
+
+ expect($token->fresh()->revoked)->toBeFalse();
+});
+
+it('requires authentication', function (): void {
+ $this->get(route('app.mcp.index'))->assertRedirect();
+});
+
+it('forbids users without workspace access', function (): void {
+ $outsider = User::factory()->create();
+ subscribeAccount($outsider->account);
+ $outsider->update(['current_workspace_id' => $this->workspace->id]);
+
+ $this->actingAs($outsider->fresh())
+ ->get(route('app.mcp.index'))
+ ->assertForbidden();
+});
+
+it('redirects to welcome when the account has no app access', function (): void {
+ $this->user->account->subscriptions()->delete();
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.mcp.index'))
+ ->assertRedirect(route('app.welcome.persona'));
+});
diff --git a/tests/Feature/Middleware/EnsureAccountReadyTest.php b/tests/Feature/Middleware/EnsureAccountReadyTest.php
index d48f22e4f..af80bcbff 100644
--- a/tests/Feature/Middleware/EnsureAccountReadyTest.php
+++ b/tests/Feature/Middleware/EnsureAccountReadyTest.php
@@ -11,13 +11,28 @@
config()->set('trypost.self_hosted', false);
});
-test('redirects to onboarding when account has no active subscription', function () {
+test('redirects owners to welcome when account has no active subscription', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
$this->actingAs($user)
->get(route('app.calendar'))
- ->assertRedirect(route('app.onboarding'));
+ ->assertRedirect(route('app.welcome.persona'));
+});
+
+test('redirects members without app access straight to subscription required', function () {
+ $owner = User::factory()->create();
+ $member = User::factory()->create(['account_id' => $owner->account_id]);
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $owner->account_id,
+ 'user_id' => $owner->id,
+ ]);
+ $workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+ $member->update(['current_workspace_id' => $workspace->id]);
+
+ $this->actingAs($member->fresh())
+ ->get(route('app.calendar'))
+ ->assertRedirect(route('app.welcome.subscription-required'));
});
test('redirects to workspace create when subscribed but no workspace', function () {
diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
index 5969a0eda..462bd4dff 100644
--- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
+++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
@@ -32,7 +32,7 @@
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
- $response->assertRedirect(route('app.onboarding'));
+ $response->assertRedirect(route('app.welcome.persona'));
});
test('user with active subscription can access the app', function () {
diff --git a/tests/Feature/Migrations/OnboardingDismissBackfillMigrationTest.php b/tests/Feature/Migrations/OnboardingDismissBackfillMigrationTest.php
new file mode 100644
index 000000000..22741aa16
--- /dev/null
+++ b/tests/Feature/Migrations/OnboardingDismissBackfillMigrationTest.php
@@ -0,0 +1,142 @@
+ false,
+ 'trypost.billing.require_card_for_trial' => true,
+ ]);
+
+ $this->runBackfill = function (): void {
+ $migration = require database_path(
+ 'migrations/2026_07_29_183500_backfill_onboarding_dismissed_for_accounts_with_app_access.php',
+ );
+
+ $migration->up();
+ };
+});
+
+test('dismisses accounts with past_due subscriptions that still have app access', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ $user = User::factory()->create();
+ $account = $user->account;
+
+ $account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'past_due',
+ 'stripe_price' => 'price_123',
+ ]);
+
+ expect($account->fresh()->hasAppAccess())->toBeTrue()
+ ->and($account->fresh()->onboarding_dismissed_at)->toBeNull();
+
+ ($this->runBackfill)();
+
+ expect($account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('dismisses accounts on a canceled subscription that is still in grace', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ $user = User::factory()->create();
+ $account = $user->account;
+
+ $account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'canceled',
+ 'stripe_price' => 'price_123',
+ 'ends_at' => now()->addDays(5),
+ ]);
+
+ expect($account->fresh()->hasAppAccess())->toBeTrue();
+
+ ($this->runBackfill)();
+
+ expect($account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('does not dismiss accounts without app access', function () {
+ $user = User::factory()->create();
+
+ ($this->runBackfill)();
+
+ expect($user->account->fresh()->onboarding_dismissed_at)->toBeNull();
+});
+
+test('does not overwrite already completed or dismissed accounts', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ $completed = User::factory()->create();
+ subscribeAccount($completed->account);
+ $completed->account->update(['onboarding_completed_at' => now()->subDay()]);
+
+ $dismissed = User::factory()->create();
+ subscribeAccount($dismissed->account);
+ $dismissed->account->update(['onboarding_dismissed_at' => now()->subDay()]);
+
+ ($this->runBackfill)();
+
+ expect($completed->account->fresh()->onboarding_dismissed_at)->toBeNull()
+ ->and($completed->account->fresh()->onboarding_completed_at?->equalTo(now()->subDay()))->toBeTrue()
+ ->and($dismissed->account->fresh()->onboarding_dismissed_at?->equalTo(now()->subDay()))->toBeTrue();
+});
+
+test('dismisses generic-trial accounts when card is not required', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ config(['trypost.billing.require_card_for_trial' => false]);
+
+ $user = User::factory()->create();
+ DB::table('accounts')->where('id', $user->account_id)->update([
+ 'trial_ends_at' => now()->addDays(7),
+ ]);
+
+ expect($user->account->fresh()->hasAppAccess())->toBeTrue();
+
+ ($this->runBackfill)();
+
+ expect($user->account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('dismisses every account in self hosted mode regardless of subscription', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ config(['trypost.self_hosted' => true]);
+
+ $withoutSubscription = User::factory()->create();
+ $withSubscription = User::factory()->create();
+ subscribeAccount($withSubscription->account);
+
+ $alreadyCompleted = User::factory()->create();
+ $alreadyCompleted->account->update(['onboarding_completed_at' => now()->subDay()]);
+
+ ($this->runBackfill)();
+
+ expect($withoutSubscription->account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue()
+ ->and($withSubscription->account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue()
+ ->and($alreadyCompleted->account->fresh()->onboarding_dismissed_at)->toBeNull();
+});
+
+test('stamps every matching account through the chunked update', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ $accounts = collect();
+ for ($i = 0; $i < 3; $i++) {
+ $user = User::factory()->create();
+ subscribeAccount($user->account);
+ $accounts->push($user->account);
+ }
+
+ ($this->runBackfill)();
+
+ foreach ($accounts as $account) {
+ expect($account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue();
+ }
+});
diff --git a/tests/Feature/Observers/PostObserverTest.php b/tests/Feature/Observers/PostObserverTest.php
index ebda452b2..ab840afce 100644
--- a/tests/Feature/Observers/PostObserverTest.php
+++ b/tests/Feature/Observers/PostObserverTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Events\OnboardingStatusUpdated;
use App\Events\PostCreated;
use App\Models\Post;
use App\Models\User;
@@ -9,7 +10,7 @@
use Illuminate\Support\Facades\Event;
test('creating a post dispatches PostCreated via the observer', function () {
- Event::fake([PostCreated::class]);
+ Event::fake([PostCreated::class, OnboardingStatusUpdated::class]);
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
@@ -23,10 +24,15 @@
PostCreated::class,
fn (PostCreated $event) => $event->post->id === $post->id,
);
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event) => $event->workspaceId === $workspace->id,
+ );
});
test('creating a post quietly does not dispatch PostCreated', function () {
- Event::fake([PostCreated::class]);
+ Event::fake([PostCreated::class, OnboardingStatusUpdated::class]);
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
@@ -37,4 +43,5 @@
]);
Event::assertNotDispatched(PostCreated::class);
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
});
diff --git a/tests/Feature/Onboarding/AccountOnboardingTimestampsTest.php b/tests/Feature/Onboarding/AccountOnboardingTimestampsTest.php
new file mode 100644
index 000000000..9943d79bc
--- /dev/null
+++ b/tests/Feature/Onboarding/AccountOnboardingTimestampsTest.php
@@ -0,0 +1,20 @@
+create();
+ $account = $user->account;
+
+ $account->update([
+ 'onboarding_completed_at' => now(),
+ 'onboarding_dismissed_at' => now()->subMinute(),
+ ]);
+
+ $account->refresh();
+
+ expect($account->onboarding_completed_at)->not->toBeNull()
+ ->and($account->onboarding_dismissed_at)->not->toBeNull();
+});
diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php
index 92f1a0dc8..7a8d27858 100644
--- a/tests/Feature/Onboarding/OnboardingControllerTest.php
+++ b/tests/Feature/Onboarding/OnboardingControllerTest.php
@@ -2,587 +2,517 @@
declare(strict_types=1);
-use App\Actions\Billing\StartSubscriptionCheckout;
-use App\Enums\Plan\Slug;
+use App\Actions\Onboarding\ResolveOnboardingStatus;
+use App\Enums\PostHog\OnboardingEvent;
use App\Enums\SocialAccount\Platform;
-use App\Enums\User\Goal;
-use App\Enums\User\Persona;
-use App\Enums\User\ReferralSource;
+use App\Enums\UserWorkspace\Role;
+use App\Events\OnboardingStatusUpdated;
use App\Jobs\PostHog\SendEvent;
-use App\Models\Account;
-use App\Models\Plan;
+use App\Models\AccessToken;
+use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Event;
beforeEach(function () {
- config(['trypost.self_hosted' => false]);
- $this->user = User::factory()->create();
-});
-
-/**
- * Give the acting user a current workspace under their account.
- */
-function onboardingWorkspace(User $user): Workspace
-{
- $workspace = Workspace::factory()->create([
- 'user_id' => $user->id,
- 'account_id' => $user->account_id,
- ]);
-
- $user->update(['current_workspace_id' => $workspace->id]);
-
- return $workspace;
-}
-
-function subscribeOnboardingAccount(Account $account): void
-{
- $account->subscriptions()->create([
- 'type' => Account::SUBSCRIPTION_NAME,
- 'stripe_id' => 'sub_'.fake()->uuid(),
- 'stripe_status' => 'active',
- 'stripe_price' => 'price_123',
+ config([
+ 'trypost.self_hosted' => false,
+ 'services.posthog.enabled' => true,
+ 'services.posthog.api_key' => 'phc_test',
]);
-}
-
-test('onboarding renders the persona selection for an unsubscribed account', function () {
- $response = $this->actingAs($this->user)->get(route('app.onboarding'));
-
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('onboarding/Index')
- ->has('personas', count(Persona::cases()))
- );
-});
-test('onboarding redirects to calendar in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
-
- $response = $this->actingAs($this->user)->get(route('app.onboarding'));
-
- $response->assertRedirect(route('app.calendar'));
-});
-
-test('onboarding redirects to calendar when already subscribed', function () {
- subscribeOnboardingAccount($this->user->account);
-
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding'));
-
- $response->assertRedirect(route('app.calendar'));
-});
+ Bus::fake();
-test('onboarding store rejects an invalid persona', function () {
- $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
- 'persona' => 'not-a-persona',
+ $this->user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+ $this->user->refresh();
- $response->assertSessionHasErrors('persona');
- expect($this->user->fresh()->persona)->toBeNull();
-});
-
-test('onboarding store requires a persona', function () {
- $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), []);
-
- $response->assertSessionHasErrors('persona');
+ subscribeAccount($this->user->account);
});
-test('onboarding store does nothing in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
-
- $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
- 'persona' => Persona::Agency->value,
+test('onboarding renders activation status and connection props', function () {
+ $socialAccount = SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
]);
- $response->assertRedirect(route('app.calendar'));
- expect($this->user->fresh()->persona)->toBeNull();
-});
-
-test('onboarding store saves the persona, mirrors to PostHog and advances to the goals step', function () {
- config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
- Bus::fake();
-
- $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
- 'persona' => Persona::Agency->value,
+ $this->actingAs($this->user)
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('onboarding/Index', false)
+ ->where('status.mcp_connected', false)
+ ->where('status.social_connected', true)
+ ->where('status.first_post_created', false)
+ ->where('status.all_complete', false)
+ ->where('status.show_residual', true)
+ ->where('status.completed_at', null)
+ ->where('status.dismissed_at', null)
+ ->where('mcpUrl', route('mcp.trypost'))
+ ->where('canDismiss', true)
+ ->missing('mcpClients')
+ ->where('samplePrompt', __('onboarding.first_post.sample_prompt'))
+ ->has('platforms', collect(Platform::cases())->filter->isConnectable()->count())
+ ->where('accounts.0.id', $socialAccount->id)
+ );
+
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'distinctId') === $this->user->id
+ && data_get($event->payload, 'event') === OnboardingEvent::Viewed->value);
+});
+
+test('onboarding flags the social step when only a sibling workspace is connected', function () {
+ $otherWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
]);
+ SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]);
- $response->assertRedirect(route('app.onboarding.goals'));
- expect($this->user->fresh()->persona)->toBe(Persona::Agency);
-
- Bus::assertDispatched(SendEvent::class);
+ $this->actingAs($this->user)
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('status.social_connected', true)
+ ->where('accounts', [])
+ ->where('socialConnectedElsewhere', true)
+ );
});
-test('onboarding store redirects an already-subscribed account to the calendar', function () {
- subscribeOnboardingAccount($this->user->account);
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [
- 'persona' => Persona::Agency->value,
- ]);
+test('onboarding does not flag social elsewhere when the current workspace is connected', function () {
+ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
- $response->assertRedirect(route('app.calendar'));
- expect($this->user->fresh()->persona)->toBeNull();
+ $this->actingAs($this->user)
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('status.social_connected', true)
+ ->where('socialConnectedElsewhere', false)
+ );
});
-test('goals renders the goal selection for an account that picked a persona', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
+test('onboarding does not capture viewed during a partial reload', function () {
+ $response = $this->actingAs($this->user)
+ ->get(route('app.onboarding'))
+ ->assertOk();
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.goals'));
+ Bus::fake();
- $response->assertOk();
$response->assertInertia(fn ($page) => $page
- ->component('onboarding/Goals')
- ->has('goals', count(Goal::cases()))
+ ->reloadOnly(['status', 'accounts', 'onboardingResidual'], fn ($reload) => $reload
+ ->has('status')
+ ->has('accounts')
+ )
);
-});
-
-test('goals redirects to the persona step when no persona was chosen', function () {
- $response = $this->actingAs($this->user)->get(route('app.onboarding.goals'));
-
- $response->assertRedirect(route('app.onboarding'));
-});
-
-test('goals redirects to calendar in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
-
- $response = $this->actingAs($this->user)->get(route('app.onboarding.goals'));
-
- $response->assertRedirect(route('app.calendar'));
-});
-
-test('goals redirects to calendar when already subscribed', function () {
- subscribeOnboardingAccount($this->user->account);
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.goals'));
-
- $response->assertRedirect(route('app.calendar'));
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value,
+ );
});
-test('goals store requires at least one goal', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
+test('onboarding can be dismissed', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), ['goals' => []]);
+ $this->actingAs($this->user)
+ ->post(route('app.onboarding.dismiss'))
+ ->assertRedirect(route('app.calendar'));
- $response->assertSessionHasErrors('goals');
- expect($this->user->fresh()->goals)->toBeNull();
-});
-
-test('goals store rejects an invalid goal', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
+ expect($this->user->account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue();
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
- 'goals' => ['not-a-goal'],
- ]);
-
- $response->assertSessionHasErrors('goals.0');
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Skipped->value);
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id,
+ );
});
-test('goals store accepts any combination of valid goals', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
+test('onboarding dismiss with stay redirects back instead of to the calendar', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
- 'goals' => [Goal::JustExploring->value, Goal::SaveTime->value],
- ]);
+ $this->actingAs($this->user)
+ ->from(route('app.posts.index'))
+ ->post(route('app.onboarding.dismiss'), ['stay' => true])
+ ->assertRedirect(route('app.posts.index'));
- $response->assertRedirect(route('app.onboarding.referral-source'));
- expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value, Goal::SaveTime->value]);
+ expect($this->user->account->fresh()->onboarding_dismissed_at?->equalTo(now()))->toBeTrue();
});
-test('goals store saves the goals, mirrors to PostHog and advances to the referral-source step', function () {
- config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
- Bus::fake();
-
- $this->user->update(['persona' => Persona::Agency->value]);
+test('dismissed accounts are redirected away from onboarding index', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
- 'goals' => [Goal::SaveTime->value, Goal::AiContent->value],
- ]);
-
- $response->assertRedirect(route('app.onboarding.referral-source'));
- expect($this->user->fresh()->goals)->toBe([Goal::SaveTime->value, Goal::AiContent->value]);
-
- Bus::assertDispatched(SendEvent::class);
-});
-
-test('goals store saves just exploring on its own as a real signal', function () {
- config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
- $this->user->update(['persona' => Persona::Agency->value]);
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.onboarding'))
+ ->assertRedirect(route('app.calendar'));
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
- 'goals' => [Goal::JustExploring->value],
- ]);
-
- $response->assertRedirect(route('app.onboarding.referral-source'));
- expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value]);
-
- Bus::assertDispatched(SendEvent::class);
-});
-
-test('goals store redirects to the persona step when no persona was chosen', function () {
- $response = $this->actingAs($this->user)->post(route('app.onboarding.goals.store'), [
- 'goals' => [Goal::SaveTime->value],
- ]);
-
- $response->assertRedirect(route('app.onboarding'));
- expect($this->user->fresh()->goals)->toBeNull();
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value,
+ );
});
-test('goals store does nothing in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
- $this->user->update(['persona' => Persona::Agency->value]);
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
- 'goals' => [Goal::SaveTime->value],
- ]);
-
- $response->assertRedirect(route('app.calendar'));
- expect($this->user->fresh()->goals)->toBeNull();
-});
+test('completed accounts are redirected away from onboarding on full visits', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ $this->user->account->update(['onboarding_completed_at' => now()]);
-test('referral source renders the source selection for an account that picked persona and goals', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
+ Bus::fake();
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.referral-source'));
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.onboarding'))
+ ->assertRedirect(route('app.calendar'));
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('onboarding/ReferralSource')
- ->has('sources', count(ReferralSource::cases()))
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value,
);
});
-test('referral source redirects to the persona step when no persona was chosen', function () {
- $response = $this->actingAs($this->user)->get(route('app.onboarding.referral-source'));
-
- $response->assertRedirect(route('app.onboarding'));
-});
+test('partial reloads do not consume the just-completed celebration session flag', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+
+ // Stamp during an authenticated request so put() writes the real session key
+ // (not a persistent withSession stub that would mask flash aging).
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.calendar'))
+ ->assertOk();
+
+ expect(app(ResolveOnboardingStatus::class)->markCompleted($this->user->fresh()))->toBeTrue()
+ ->and(session()->get(ResolveOnboardingStatus::JUST_COMPLETED_SESSION_KEY))->toBeTrue();
+
+ $this->actingAs($this->user->fresh())
+ ->withHeaders(inertiaPartialHeaders(
+ component: 'onboarding/Index',
+ only: 'status,accounts,onboardingResidual',
+ ))
+ ->get(route('app.onboarding'))
+ ->assertOk();
+
+ // put() survives Echo/poll partials so a later full visit can celebrate.
+ expect(session()->get(ResolveOnboardingStatus::JUST_COMPLETED_SESSION_KEY))->toBeTrue();
+
+ // Drop partial headers — withHeaders persists on the test case.
+ $this->flushHeaders();
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('onboarding/Index', false)
+ ->where('status.all_complete', true)
+ ->where('status.completed_at', now()->toIso8601String())
+ );
+
+ expect(session()->get(ResolveOnboardingStatus::JUST_COMPLETED_SESSION_KEY))->toBeNull();
+});
+
+test('completed accounts still see celebration after just-completed session flag', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+
+ // Stamp during an authenticated request (OAuth/observer path) so the flag is set.
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.calendar'))
+ ->assertOk();
+
+ expect(app(ResolveOnboardingStatus::class)->markCompleted($this->user->fresh()))->toBeTrue();
-test('referral source redirects to the goals step when a persona was chosen but no goals', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
-
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.referral-source'));
+ Bus::fake();
- $response->assertRedirect(route('app.onboarding.goals'));
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('onboarding/Index', false)
+ ->where('status.all_complete', true)
+ ->where('status.completed_at', now()->toIso8601String())
+ );
+
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value,
+ );
});
-test('referral source redirects to calendar in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
+test('completed accounts still see celebration on onboarding partial reloads', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
- $response = $this->actingAs($this->user)->get(route('app.onboarding.referral-source'));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+ $this->user->account->update(['onboarding_completed_at' => now()]);
- $response->assertRedirect(route('app.calendar'));
+ $this->actingAs($this->user->fresh())
+ ->withHeaders(inertiaPartialHeaders(
+ component: 'onboarding/Index',
+ only: 'status,accounts,onboardingResidual',
+ ))
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertJsonPath('component', 'onboarding/Index')
+ ->assertJsonPath('props.status.all_complete', true)
+ ->assertJsonPath('props.status.completed_at', now()->toIso8601String());
});
-test('referral source redirects to calendar when already subscribed', function () {
- subscribeOnboardingAccount($this->user->account);
+test('dismiss after completion redirects without rewriting dismissed_at', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.referral-source'));
+ $this->user->account->update(['onboarding_completed_at' => now()]);
- $response->assertRedirect(route('app.calendar'));
-});
+ Bus::fake();
-test('referral source store requires a source', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.onboarding.dismiss'))
+ ->assertRedirect(route('app.calendar'));
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), []);
+ expect($this->user->account->fresh()->onboarding_dismissed_at)->toBeNull();
- $response->assertSessionHasErrors('referral_source');
- expect($this->user->fresh()->referral_source)->toBeNull();
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Skipped->value,
+ );
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
});
-test('referral source store rejects an invalid source', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
+test('onboarding cannot be completed before every activation step', function () {
+ $this->actingAs($this->user)
+ ->post(route('app.onboarding.complete'))
+ ->assertRedirect(route('app.onboarding'));
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => 'not-a-source',
- ]);
+ expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull();
- $response->assertSessionHasErrors('referral_source');
- expect($this->user->fresh()->referral_source)->toBeNull();
+ Bus::assertNotDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value);
});
-test('referral source store saves the source, mirrors to PostHog and advances to the connect step', function () {
- config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
- Bus::fake();
+test('onboarding completes after every activation step', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => ReferralSource::ProductHunt->value,
- ]);
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.onboarding.complete'))
+ ->assertRedirect(route('app.calendar'));
- $response->assertRedirect(route('app.onboarding.connect'));
- expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt);
+ expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
- Bus::assertDispatched(SendEvent::class);
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value);
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id,
+ );
});
-test('referral source store redirects to the persona step when no persona was chosen', function () {
- $response = $this->actingAs($this->user)->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => ReferralSource::Google->value,
- ]);
+test('onboarding complete does not re-fire completed when already stamped', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
- $response->assertRedirect(route('app.onboarding'));
- expect($this->user->fresh()->referral_source)->toBeNull();
-});
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+ $this->user->account->update(['onboarding_completed_at' => now()]);
-test('referral source store redirects to the goals step when no goals were chosen', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
+ Bus::fake();
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => ReferralSource::Google->value,
- ]);
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.onboarding.complete'))
+ ->assertRedirect(route('app.calendar'));
- $response->assertRedirect(route('app.onboarding.goals'));
- expect($this->user->fresh()->referral_source)->toBeNull();
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value,
+ );
});
-test('referral source store does nothing in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => ReferralSource::Google->value,
+test('members do not see the skip control', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, [
+ 'role' => Role::Member->value,
]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
- $response->assertRedirect(route('app.calendar'));
- expect($this->user->fresh()->referral_source)->toBeNull();
+ $this->actingAs($member->fresh())
+ ->get(route('app.onboarding'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('canDismiss', false));
});
-test('referral source store redirects an already-subscribed account to the calendar', function () {
- subscribeOnboardingAccount($this->user->account);
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => ReferralSource::Google->value,
+test('only the account owner can dismiss onboarding', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, [
+ 'role' => Role::Member->value,
]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
- $response->assertRedirect(route('app.calendar'));
- expect($this->user->fresh()->referral_source)->toBeNull();
-});
-
-test('connect redirects to the referral-source step when persona and goals chosen but no source', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
- onboardingWorkspace($this->user);
+ $this->actingAs($member->fresh())
+ ->post(route('app.onboarding.dismiss'))
+ ->assertForbidden();
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
-
- $response->assertRedirect(route('app.onboarding.referral-source'));
+ expect($this->user->account->fresh()->onboarding_dismissed_at)->toBeNull();
});
-test('connect redirects to the goals step when a persona was chosen but no goals', function () {
- $this->user->update(['persona' => Persona::Agency->value]);
- onboardingWorkspace($this->user);
+test('teammates can stamp completion via the complete endpoint', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
- $response->assertRedirect(route('app.onboarding.goals'));
-});
-
-test('connect renders the network grid for an unsubscribed account that picked a persona', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
- onboardingWorkspace($this->user);
-
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
-
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('onboarding/Connect')
- ->has('platforms')
- ->has('platforms.0.network')
- ->has('accounts')
- );
-});
-
-test('connect passes the workspace plan so the client can fire begin_checkout', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
- onboardingWorkspace($this->user);
-
- $plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
-
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, [
+ 'role' => Role::Member->value,
+ ]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('onboarding/Connect')
- ->where('plan.name', $plan->name)
- ->where('plan.interval', 'monthly')
- );
-});
+ $otherWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
-test('connect offers a single linkedin card and no standalone linkedin page card', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
- onboardingWorkspace($this->user);
+ $this->actingAs($member->fresh())
+ ->post(route('app.onboarding.complete'))
+ ->assertRedirect(route('app.calendar'));
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
+ expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('onboarding/Connect')
- ->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::LinkedIn->value)
- && ! collect($platforms)->contains('value', Platform::LinkedInPage->value)
- )
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id,
);
-});
-
-test('connect lists the workspace social accounts already connected', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
- $workspace = onboardingWorkspace($this->user);
- SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
-
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
-
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('onboarding/Connect')
- ->has('accounts', 1)
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $otherWorkspace->id,
);
});
-test('connect redirects back to the persona step when no persona was chosen', function () {
- onboardingWorkspace($this->user);
+test('complete stamps when activation finished on another workspace', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ Event::fake([OnboardingStatusUpdated::class]);
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
- $response->assertRedirect(route('app.onboarding'));
-});
-
-test('connect redirects to calendar in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
-
- $response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
-
- $response->assertRedirect(route('app.calendar'));
-});
-
-test('connect redirects to calendar when already subscribed', function () {
- subscribeOnboardingAccount($this->user->account);
-
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
-
- $response->assertRedirect(route('app.calendar'));
-});
-
-test('checkout blocks and redirects back when no network is connected', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
- onboardingWorkspace($this->user);
-
- $this->mock(StartSubscriptionCheckout::class)
- ->shouldReceive('redirect')
- ->never();
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
-
- $response->assertRedirect(route('app.onboarding.connect'));
-});
-
-test('checkout starts monthly checkout once at least one network is connected', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
- $workspace = onboardingWorkspace($this->user);
- SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
-
- Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
- 'stripe_monthly_price_id' => 'price_monthly_test',
- 'stripe_yearly_price_id' => 'price_yearly_test',
+ $emptyWorkspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
]);
+ $this->user->update(['current_workspace_id' => $emptyWorkspace->id]);
- $this->mock(StartSubscriptionCheckout::class)
- ->shouldReceive('redirect')
- ->once()
- ->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $priceId === 'price_monthly_test')
- ->andReturn(redirect()->route('app.calendar'));
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
-
- $response->assertRedirect(route('app.calendar'));
-});
-
-test('checkout redirects an already-subscribed account to the calendar', function () {
- subscribeOnboardingAccount($this->user->account);
-
- $this->mock(StartSubscriptionCheckout::class)
- ->shouldReceive('redirect')
- ->never();
-
- $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.onboarding.complete'))
+ ->assertRedirect(route('app.calendar'));
- $response->assertRedirect(route('app.calendar'));
+ expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
});
-test('checkout does nothing in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
-
- $this->mock(StartSubscriptionCheckout::class)
- ->shouldReceive('redirect')
- ->never();
+test('complete after dismiss redirects without stamping completion', function () {
+ Carbon::setTestNow('2026-07-24 12:00:00');
- $response = $this->actingAs($this->user)->post(route('app.onboarding.checkout'));
+ AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]));
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
- $response->assertRedirect(route('app.calendar'));
-});
+ Bus::fake();
-test('connect redirects to workspace creation when no workspace exists', function () {
- $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.onboarding.complete'))
+ ->assertRedirect(route('app.calendar'));
- $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
+ expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull();
- $response->assertRedirect(route('app.workspaces.create'));
+ Bus::assertNotDispatched(
+ SendEvent::class,
+ fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value,
+ );
});
-test('a user walks the full onboarding flow from the account gate to stripe checkout', function () {
- Plan::where('slug', Slug::Workspace)->firstOrFail()->update(['stripe_monthly_price_id' => 'price_monthly_test']);
- $workspace = onboardingWorkspace($this->user);
-
- // The account gate sends an unsubscribed user into onboarding.
- $this->actingAs($this->user->fresh())->get(route('app.calendar'))
- ->assertRedirect(route('app.onboarding'));
-
- // Persona advances to goals.
- $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), ['persona' => Persona::Creator->value])
- ->assertRedirect(route('app.onboarding.goals'));
-
- // Goals advances to the referral-source step.
- $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
- 'goals' => [Goal::AiContent->value, Goal::SaveTime->value],
- ])->assertRedirect(route('app.onboarding.referral-source'));
-
- // Referral source advances to connect.
- $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
- 'referral_source' => ReferralSource::Friend->value,
- ])->assertRedirect(route('app.onboarding.connect'));
-
- // Connect renders now that persona, goals, a source and a workspace are present.
- $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'))->assertOk();
-
- // Checkout blocks until a network is connected.
- $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'))
- ->assertRedirect(route('app.onboarding.connect'));
+test('unsubscribed accounts are redirected to welcome by middleware', function (string $routeName, string $method) {
+ $this->user->account->subscriptions()->delete();
+ $this->actingAs($this->user->fresh());
- // Once a network is connected, checkout hands off to Stripe.
- SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
+ $response = $method === 'get'
+ ? $this->get(route($routeName))
+ : $this->post(route($routeName));
- $this->mock(StartSubscriptionCheckout::class)
- ->shouldReceive('redirect')
- ->once()
- ->andReturn(redirect('https://checkout.stripe.test/session'));
+ $response->assertRedirect(route('app.welcome.persona'));
+})->with([
+ 'index' => ['app.onboarding', 'get'],
+ 'dismiss' => ['app.onboarding.dismiss', 'post'],
+ 'complete' => ['app.onboarding.complete', 'post'],
+]);
- $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'))
- ->assertRedirect('https://checkout.stripe.test/session');
-
- expect($this->user->fresh()->persona)->toBe(Persona::Creator);
- expect($this->user->fresh()->goals)->toBe([Goal::AiContent->value, Goal::SaveTime->value]);
- expect($this->user->fresh()->referral_source)->toBe(ReferralSource::Friend);
-});
-
-test('a self-hosted user never enters the onboarding flow', function () {
+test('self hosted activation endpoints redirect to calendar', function (string $routeName, string $method) {
config(['trypost.self_hosted' => true]);
- onboardingWorkspace($this->user);
+ $this->actingAs($this->user);
- // The app is reachable directly, with no subscription.
- $this->actingAs($this->user->fresh())->get(route('app.calendar'))->assertOk();
+ $response = $method === 'get'
+ ? $this->get(route($routeName))
+ : $this->post(route($routeName));
- // Every onboarding step just bounces to the calendar.
- foreach (['app.onboarding', 'app.onboarding.goals', 'app.onboarding.referral-source', 'app.onboarding.connect'] as $routeName) {
- $this->actingAs($this->user->fresh())->get(route($routeName))->assertRedirect(route('app.calendar'));
- }
-});
+ $response->assertRedirect(route('app.calendar'));
+})->with([
+ 'index' => ['app.onboarding', 'get'],
+ 'dismiss' => ['app.onboarding.dismiss', 'post'],
+ 'complete' => ['app.onboarding.complete', 'post'],
+]);
diff --git a/tests/Feature/Onboarding/OnboardingResidualShareTest.php b/tests/Feature/Onboarding/OnboardingResidualShareTest.php
new file mode 100644
index 000000000..21ff10473
--- /dev/null
+++ b/tests/Feature/Onboarding/OnboardingResidualShareTest.php
@@ -0,0 +1,72 @@
+ false]);
+
+ $this->user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+ $this->user->refresh();
+
+ subscribeAccount($this->user->account);
+});
+
+test('shares the onboarding residual progress for subscribed accounts', function () {
+ $this->actingAs($this->user)
+ ->get(route('app.calendar'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('onboardingResidual.completed', 0)
+ ->where('onboardingResidual.total', ResolveOnboardingStatus::TOTAL_STEPS)
+ );
+});
+
+test('does not share the onboarding residual state after dismissal', function () {
+ $this->user->account->update(['onboarding_dismissed_at' => now()]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.calendar'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('onboardingResidual', false));
+});
+
+test('does not share the onboarding residual state after completion', function () {
+ $this->user->account->update(['onboarding_completed_at' => now()]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.calendar'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('onboardingResidual', false));
+});
+
+test('does not share the onboarding residual with workspace members', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $this->workspace->members()->attach($member->id, [
+ 'role' => Role::Member->value,
+ ]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
+
+ $this->actingAs($member->fresh())
+ ->get(route('app.calendar'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('onboardingResidual', false));
+});
+
+test('does not share the onboarding residual in self-hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $this->actingAs($this->user)
+ ->get(route('app.calendar'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('onboardingResidual', false));
+});
diff --git a/tests/Feature/SocialControllerTest.php b/tests/Feature/SocialControllerTest.php
index 12b960f0f..08deef8a1 100644
--- a/tests/Feature/SocialControllerTest.php
+++ b/tests/Feature/SocialControllerTest.php
@@ -96,7 +96,7 @@
);
});
-test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () {
+test('an unsubscribed account can disconnect during welcome (no active subscription required)', function () {
config(['trypost.self_hosted' => false]);
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
@@ -108,6 +108,14 @@
expect(SocialAccount::find($account->id))->toBeNull();
});
+test('an unsubscribed account cannot start a social connection', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $this->actingAs($this->user)
+ ->get(route('app.social.linkedin.connect'))
+ ->assertRedirect(route('app.welcome.persona'));
+});
+
test('accounts index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php
new file mode 100644
index 000000000..0f6e23832
--- /dev/null
+++ b/tests/Feature/Welcome/WelcomeControllerTest.php
@@ -0,0 +1,440 @@
+ false]);
+ $this->user = User::factory()->create();
+});
+
+test('welcome redirects to the persona step', function () {
+ $this->actingAs($this->user)
+ ->get(route('app.welcome'))
+ ->assertRedirect(route('app.welcome.persona'));
+});
+
+test('persona renders for an unsubscribed account', function () {
+ $this->actingAs($this->user)
+ ->get(route('app.welcome.persona'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('welcome/Persona', false)
+ ->has('personas', count(Persona::cases()))
+ );
+});
+
+test('persona requires a valid selection', function (array $payload) {
+ $this->actingAs($this->user)
+ ->post(route('app.welcome.persona.store'), $payload)
+ ->assertSessionHasErrors('persona');
+
+ expect($this->user->fresh()->persona)->toBeNull();
+})->with([
+ 'missing' => [[]],
+ 'invalid' => [['persona' => 'not-a-persona']],
+]);
+
+test('persona store saves the selection mirrors it to PostHog and advances to goals', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Bus::fake();
+
+ $this->actingAs($this->user)
+ ->post(route('app.welcome.persona.store'), ['persona' => Persona::Agency->value])
+ ->assertRedirect(route('app.welcome.goals'));
+
+ expect($this->user->fresh()->persona)->toBe(Persona::Agency);
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'distinctId') === $this->user->id
+ && data_get($event->payload, 'event') === WelcomeEvent::PersonaSaved->value
+ && data_get($event->payload, 'properties.persona') === Persona::Agency->value);
+});
+
+test('goals redirects to persona until a persona is selected', function () {
+ $this->actingAs($this->user)
+ ->get(route('app.welcome.goals'))
+ ->assertRedirect(route('app.welcome.persona'));
+});
+
+test('goals renders after a persona is selected', function () {
+ $this->user->update(['persona' => Persona::Agency->value]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.goals'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('welcome/Goals', false)
+ ->has('goals', count(Goal::cases()))
+ );
+});
+
+test('goals requires at least one valid goal', function (array $goals, string $error) {
+ $this->user->update(['persona' => Persona::Agency->value]);
+
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.welcome.goals.store'), ['goals' => $goals])
+ ->assertSessionHasErrors($error);
+
+ expect($this->user->fresh()->goals)->toBeNull();
+})->with([
+ 'empty' => [[], 'goals'],
+ 'invalid' => [['not-a-goal'], 'goals.0'],
+]);
+
+test('goals store saves choices mirrors them to PostHog and advances to referral source', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Bus::fake();
+ $this->user->update(['persona' => Persona::Creator->value]);
+
+ $goals = [Goal::AiContent->value, Goal::SaveTime->value];
+
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.welcome.goals.store'), ['goals' => $goals])
+ ->assertRedirect(route('app.welcome.referral-source'));
+
+ expect($this->user->fresh()->goals)->toBe($goals);
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'event') === WelcomeEvent::GoalsSaved->value
+ && data_get($event->payload, 'properties.goals') === $goals);
+});
+
+test('completed welcome steps remain reachable when going back', function () {
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.persona'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->component('welcome/Persona', false));
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.goals'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->component('welcome/Goals', false));
+});
+
+test('referral source redirects through incomplete prior steps', function (array $attributes, string $routeName) {
+ $this->user->update($attributes);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.referral-source'))
+ ->assertRedirect(route($routeName));
+})->with([
+ 'missing persona' => [[], 'app.welcome.persona'],
+ 'missing goals' => [['persona' => Persona::Agency->value], 'app.welcome.goals'],
+]);
+
+test('referral source renders after prior steps are complete', function () {
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+ $plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.referral-source'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('welcome/ReferralSource', false)
+ ->has('sources', count(ReferralSource::cases()))
+ ->where('canCheckout', true)
+ ->where('plan.name', $plan->name)
+ ->where('plan.interval', 'monthly')
+ );
+});
+
+test('referral source requires a valid selection', function (array $payload) {
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.welcome.referral-source.store'), $payload)
+ ->assertSessionHasErrors('referral_source');
+
+ expect($this->user->fresh()->referral_source)->toBeNull();
+})->with([
+ 'missing' => [[]],
+ 'invalid' => [['referral_source' => 'not-a-source']],
+]);
+
+test('referral source store saves the source and starts Stripe checkout without a social account', function () {
+ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
+ Bus::fake();
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+
+ Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
+ 'stripe_monthly_price_id' => 'price_monthly_test',
+ ]);
+
+ $this->mock(StartSubscriptionCheckout::class)
+ ->shouldReceive('redirect')
+ ->once()
+ ->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $account->is($this->user->account)
+ && $priceId === 'price_monthly_test'
+ && $cancelUrl === route('app.welcome.referral-source'))
+ ->andReturn(redirect('https://checkout.stripe.test/session'));
+
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.welcome.referral-source.store'), [
+ 'referral_source' => ReferralSource::ProductHunt->value,
+ ])
+ ->assertRedirect('https://checkout.stripe.test/session');
+
+ expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt);
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'event') === WelcomeEvent::ReferralSaved->value
+ && data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value);
+ Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
+ && data_get($event->payload, 'event') === WelcomeEvent::CheckoutStarted->value);
+});
+
+test('welcome steps redirect to activation for subscribed accounts with residual onboarding', function (string $routeName, string $method, array $payload = []) {
+ subscribeAccount($this->user->account);
+
+ $this->actingAs($this->user->fresh());
+
+ $response = $method === 'get'
+ ? $this->get(route($routeName))
+ : $this->post(route($routeName), $payload);
+
+ $response->assertRedirect(route('app.onboarding'));
+})->with([
+ 'persona' => ['app.welcome.persona', 'get'],
+ 'persona store' => ['app.welcome.persona.store', 'post', ['persona' => Persona::Agency->value]],
+ 'goals' => ['app.welcome.goals', 'get'],
+ 'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
+ 'referral source' => ['app.welcome.referral-source', 'get'],
+ 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
+]);
+
+test('welcome redirects subscribed accounts without residual onboarding to calendar', function (array $attributes) {
+ subscribeAccount($this->user->account);
+ $this->user->account->update($attributes);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.persona'))
+ ->assertRedirect(route('app.calendar'));
+})->with([
+ 'dismissed' => [['onboarding_dismissed_at' => now()]],
+ 'completed' => [['onboarding_completed_at' => now()]],
+]);
+
+test('welcome redirects generic-trial accounts with app access to calendar', function () {
+ config(['trypost.billing.require_card_for_trial' => false]);
+
+ $this->user->account->update([
+ 'trial_ends_at' => now()->addDays(8),
+ 'onboarding_dismissed_at' => now(),
+ ]);
+
+ expect($this->user->account->fresh()->hasAppAccess())->toBeTrue()
+ ->and($this->user->account->fresh()->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse();
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.persona'))
+ ->assertRedirect(route('app.calendar'));
+});
+
+test('welcome steps redirect to calendar in self hosted mode', function (string $routeName, string $method, array $payload = []) {
+ config(['trypost.self_hosted' => true]);
+
+ $this->actingAs($this->user);
+
+ $response = $method === 'get'
+ ? $this->get(route($routeName))
+ : $this->post(route($routeName), $payload);
+
+ $response->assertRedirect(route('app.calendar'));
+})->with([
+ 'persona' => ['app.welcome.persona', 'get'],
+ 'persona store' => ['app.welcome.persona.store', 'post', ['persona' => Persona::Agency->value]],
+ 'goals' => ['app.welcome.goals', 'get'],
+ 'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
+ 'referral source' => ['app.welcome.referral-source', 'get'],
+ 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
+]);
+
+test('legacy welcome URLs redirect to their replacement routes', function (string $legacyRoute, string $routeName) {
+ $this->actingAs($this->user)
+ ->get(route($legacyRoute))
+ ->assertRedirect(route($routeName));
+})->with([
+ 'goals' => ['app.legacy-onboarding.goals', 'app.welcome.goals'],
+ 'referral source' => ['app.legacy-onboarding.referral-source', 'app.welcome.referral-source'],
+ 'connect' => ['app.legacy-onboarding.connect', 'app.welcome.referral-source'],
+]);
+
+test('members cannot start Stripe checkout from welcome', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+ $member->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+
+ $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
+
+ // Members never reach the referral step — they are held on the
+ // subscription-required screen before any checkout attempt.
+ $this->actingAs($member->fresh())
+ ->get(route('app.welcome.referral-source'))
+ ->assertRedirect(route('app.welcome.subscription-required'));
+
+ $this->actingAs($member->fresh())
+ ->post(route('app.welcome.referral-source.store'), [
+ 'referral_source' => ReferralSource::Google->value,
+ ])
+ ->assertRedirect(route('app.welcome.subscription-required'));
+
+ expect($member->fresh()->referral_source)->toBeNull();
+});
+
+test('members without app access are held on the subscription required screen', function (string $routeName, string $method, array $payload = []) {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+
+ $this->actingAs($member->fresh());
+
+ $response = $method === 'get'
+ ? $this->get(route($routeName))
+ : $this->post(route($routeName), $payload);
+
+ $response->assertRedirect(route('app.welcome.subscription-required'));
+})->with([
+ 'persona' => ['app.welcome.persona', 'get'],
+ 'persona store' => ['app.welcome.persona.store', 'post', ['persona' => Persona::Agency->value]],
+ 'goals' => ['app.welcome.goals', 'get'],
+ 'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
+ 'referral source' => ['app.welcome.referral-source', 'get'],
+ 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
+]);
+
+test('subscription required screen renders for members without app access', function () {
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+
+ $this->actingAs($member->fresh())
+ ->get(route('app.welcome.subscription-required'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('welcome/SubscriptionRequired', false)
+ ->where('ownerName', $this->user->name)
+ );
+});
+
+test('subscription required screen sends owners back to the welcome flow', function () {
+ $this->actingAs($this->user)
+ ->get(route('app.welcome.subscription-required'))
+ ->assertRedirect(route('app.welcome.persona'));
+});
+
+test('subscription required screen sends owners with residual onboarding to activation', function () {
+ subscribeAccount($this->user->account);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.subscription-required'))
+ ->assertRedirect(route('app.onboarding'));
+});
+
+test('referral source exposes zero trial days when checkout trials are disabled', function () {
+ config(['trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 0]);
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.referral-source'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('trialDays', 0));
+});
+
+test('subscription required screen sends members with app access to the calendar', function () {
+ ['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount();
+ subscribeAccount($owner->account);
+
+ $this->actingAs($member)
+ ->get(route('app.welcome.subscription-required'))
+ ->assertRedirect(route('app.calendar'));
+});
+
+test('subscription required screen redirects to calendar in self hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $member = User::factory()->create(['account_id' => $this->user->account_id]);
+
+ $this->actingAs($member->fresh())
+ ->get(route('app.welcome.subscription-required'))
+ ->assertRedirect(route('app.calendar'));
+});
+
+test('welcome sends members with app access to the calendar', function () {
+ ['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount();
+ subscribeAccount($owner->account);
+
+ $this->actingAs($member)
+ ->get(route('app.welcome.persona'))
+ ->assertRedirect(route('app.calendar'));
+});
+
+test('referral source exposes the checkout trial length to first-time accounts', function () {
+ config(['trypost.billing.require_card_for_trial' => true]);
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.referral-source'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('trialDays', max(2, (int) config('cashier.trial_days'))));
+});
+
+test('referral source exposes zero trial days to returning accounts', function () {
+ config(['trypost.billing.require_card_for_trial' => true]);
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+ subscribeAccount($this->user->account);
+ $this->user->account->subscriptions()->update([
+ 'stripe_status' => 'canceled',
+ 'ends_at' => now()->subDay(),
+ ]);
+
+ $this->actingAs($this->user->fresh())
+ ->get(route('app.welcome.referral-source'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('trialDays', 0));
+});
+
+test('referral source store fails loudly when the monthly price is not configured', function () {
+ $this->user->update([
+ 'persona' => Persona::Agency->value,
+ 'goals' => [Goal::SaveTime->value],
+ ]);
+ Plan::where('slug', Slug::Workspace)->update(['stripe_monthly_price_id' => null]);
+
+ $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
+
+ $this->actingAs($this->user->fresh())
+ ->post(route('app.welcome.referral-source.store'), [
+ 'referral_source' => ReferralSource::Google->value,
+ ])
+ ->assertServerError();
+});
diff --git a/tests/Feature/WorkspaceBillingTest.php b/tests/Feature/WorkspaceBillingTest.php
index 5ca5ab893..395e0f432 100644
--- a/tests/Feature/WorkspaceBillingTest.php
+++ b/tests/Feature/WorkspaceBillingTest.php
@@ -57,7 +57,7 @@
$response = $this->actingAs($this->user)->get(route('app.calendar'));
- $response->assertRedirect(route('app.onboarding'));
+ $response->assertRedirect(route('app.welcome.persona'));
});
test('billing page is accessible by account owner', function () {
@@ -96,12 +96,12 @@
$response->assertForbidden();
});
-test('subscribe redirects to onboarding', function () {
+test('subscribe redirects to welcome', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
- $response->assertRedirect(route('app.onboarding'));
+ $response->assertRedirect(route('app.welcome.persona'));
});
test('stripe email returns account owner email', function () {
diff --git a/tests/Pest.php b/tests/Pest.php
index 715b1e072..b8fbaa96d 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -9,6 +9,10 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Stripe\ApiRequestor;
+use Stripe\HttpClient\ClientInterface;
use Tests\BrowserTestCase;
use Tests\TestCase;
@@ -161,6 +165,72 @@ function subscribeAccount(Account $account): void
]);
}
+/**
+ * Headers for an Inertia partial reload. Includes the asset version so local
+ * runs with a built Vite manifest don't get a 409 from the version check.
+ *
+ * @return array
+ */
+function inertiaPartialHeaders(string $component, string $only): array
+{
+ $version = '';
+
+ if (file_exists($manifest = public_path('build/manifest.json'))) {
+ $version = hash_file('xxh128', $manifest);
+ } elseif (file_exists($manifest = public_path('mix-manifest.json'))) {
+ $version = hash_file('xxh128', $manifest);
+ }
+
+ return [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ 'X-Inertia-Version' => $version,
+ 'X-Inertia-Partial-Component' => $component,
+ 'X-Inertia-Partial-Data' => $only,
+ ];
+}
+
+/**
+ * Insert an OAuth client suitable for MCP connection tests.
+ */
+function mcpOauthClient(string $name = 'My Agent'): string
+{
+ $id = (string) Str::uuid();
+
+ DB::table('oauth_clients')->insert([
+ 'id' => $id,
+ 'name' => $name,
+ 'secret' => null,
+ 'provider' => null,
+ 'redirect_uris' => '[]',
+ 'grant_types' => json_encode(['authorization_code']),
+ 'revoked' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ return $id;
+}
+
+/**
+ * Create an active OAuth access token for MCP connection tests.
+ */
+function mcpAccessToken(User $user, string $clientId): AccessToken
+{
+ $token = new AccessToken;
+ $token->forceFill([
+ 'id' => Str::random(80),
+ 'user_id' => $user->id,
+ 'client_id' => $clientId,
+ 'workspace_id' => null,
+ 'name' => 'MCP',
+ 'scopes' => [],
+ 'revoked' => false,
+ ])->save();
+
+ return $token->refresh();
+}
+
/**
* Move a member onto a shared account (stranded-member / invitee fixture).
*
@@ -221,3 +291,36 @@ function strandedMemberOnSharedAccount(
'shared_workspaces' => $shared,
];
}
+
+/**
+ * Fake the Stripe HTTP layer: the SDK's checkout session retrieval responds
+ * with the given payloads (error statuses throw the usual SDK exceptions).
+ * Returns the fake client so tests can assert how many calls were made.
+ * Reset with ApiRequestor::setHttpClient(new CurlClient()) in an afterEach.
+ *
+ * @param list, status: int}> $responses
+ */
+function fakeStripeHttp(array $responses): object
+{
+ // The SDK refuses to boot without an API key, and the testing env has none.
+ config(['cashier.secret' => 'sk_test_fake']);
+
+ $client = new class($responses) implements ClientInterface
+ {
+ public int $calls = 0;
+
+ public function __construct(private readonly array $responses) {}
+
+ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null): array
+ {
+ $this->calls++;
+ $response = $this->responses[min($this->calls - 1, count($this->responses) - 1)];
+
+ return [json_encode($response['body']), $response['status'], []];
+ }
+ };
+
+ ApiRequestor::setHttpClient($client);
+
+ return $client;
+}
diff --git a/tests/Unit/Enums/PersonaTest.php b/tests/Unit/Enums/PersonaTest.php
index b0eb2c382..72855024f 100644
--- a/tests/Unit/Enums/PersonaTest.php
+++ b/tests/Unit/Enums/PersonaTest.php
@@ -9,9 +9,9 @@
->toBe(['creator', 'freelancer', 'developer', 'startup', 'agency', 'small_business', 'marketer', 'online_store', 'other']);
});
-test('every persona has an onboarding label in every locale', function (string $locale) {
+test('every persona has a welcome label in every locale', function (string $locale) {
foreach (Persona::cases() as $persona) {
- $key = "onboarding.personas.{$persona->value}";
+ $key = "welcome.personas.{$persona->value}";
expect(__($key, [], $locale))->not->toBe($key);
}
diff --git a/tests/Unit/Events/OnboardingStatusUpdatedTest.php b/tests/Unit/Events/OnboardingStatusUpdatedTest.php
new file mode 100644
index 000000000..e90608129
--- /dev/null
+++ b/tests/Unit/Events/OnboardingStatusUpdatedTest.php
@@ -0,0 +1,259 @@
+uuid();
+ $event = new OnboardingStatusUpdated($workspaceId);
+ $channels = $event->broadcastOn();
+
+ expect($channels)->toHaveCount(1)
+ ->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
+ ->and($channels[0]->name)->toBe("private-workspace.{$workspaceId}");
+});
+
+test('event broadcasts with the workspace id', function () {
+ $workspaceId = fake()->uuid();
+ $event = new OnboardingStatusUpdated($workspaceId);
+
+ expect($event->broadcastWith())->toBe(['workspace_id' => $workspaceId]);
+});
+
+test('event broadcasts as a stable name', function () {
+ $event = new OnboardingStatusUpdated(fake()->uuid());
+
+ expect($event->broadcastAs())->toBe('onboarding.status.updated');
+});
+
+test('event waits for the database transaction to commit', function () {
+ expect(new OnboardingStatusUpdated(fake()->uuid()))
+ ->toBeInstanceOf(ShouldDispatchAfterCommit::class);
+});
+
+test('dispatchForWorkspace skips blank workspace ids', function () {
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ OnboardingStatusUpdated::dispatchForWorkspace(null);
+ OnboardingStatusUpdated::dispatchForWorkspace('');
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('broadcastForAccount notifies every workspace even after completion', function () {
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $user = User::factory()->create();
+ $workspaceA = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspaceB = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->account->update(['onboarding_completed_at' => now()]);
+
+ OnboardingStatusUpdated::broadcastForAccount($user->account->fresh());
+
+ Event::assertDispatchedTimes(OnboardingStatusUpdated::class, 2);
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceA->id,
+ );
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceB->id,
+ );
+});
+
+test('dispatchForWorkspace fans out Echo to sibling workspaces while onboarding is open', function () {
+ Event::fake([OnboardingStatusUpdated::class]);
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+ $workspaceA = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspaceB = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspaceA->id]);
+ subscribeAccount($user->account);
+
+ OnboardingStatusUpdated::dispatchForWorkspace($workspaceA->id, $user->fresh());
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceA->id,
+ );
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceB->id,
+ );
+});
+
+test('dispatchForWorkspace does not stamp when the surrounding transaction rolls back', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+ subscribeAccount($user->account);
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]));
+
+ try {
+ DB::transaction(function () use ($user, $workspace): void {
+ OnboardingStatusUpdated::dispatchForWorkspace($workspace->id, $user->fresh());
+
+ throw new RuntimeException('force rollback');
+ });
+ } catch (RuntimeException) {
+ // Expected — sync/analytics must stay inside afterCommit.
+ }
+
+ expect($user->account->fresh()->onboarding_completed_at)->toBeNull();
+});
+
+test('dispatchForWorkspace stamps completion when actor current workspace differs', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+ $readyWorkspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $otherWorkspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ // Persisted current workspace is elsewhere (API/MCP in-memory mutation case).
+ $user->update(['current_workspace_id' => $otherWorkspace->id]);
+ subscribeAccount($user->account);
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $readyWorkspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $readyWorkspace->id,
+ 'user_id' => $user->id,
+ ]));
+
+ // Align the request user to the ready workspace in memory only.
+ $actor = $user->fresh();
+ $actor->setRelation('currentWorkspace', $readyWorkspace);
+ $actor->current_workspace_id = $readyWorkspace->id;
+
+ OnboardingStatusUpdated::dispatchForWorkspace($readyWorkspace->id, $actor);
+
+ expect($user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('dispatchForWorkspace stamps when nobody is currently on the workspace', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ config(['trypost.self_hosted' => false]);
+
+ $owner = User::factory()->create(['current_workspace_id' => null]);
+ $readyWorkspace = Workspace::factory()->create([
+ 'account_id' => $owner->account_id,
+ 'user_id' => $owner->id,
+ ]);
+ subscribeAccount($owner->account);
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($owner, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $readyWorkspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $readyWorkspace->id,
+ 'user_id' => $owner->id,
+ ]));
+
+ $actor = $owner->fresh();
+ $actor->setRelation('currentWorkspace', $readyWorkspace);
+ $actor->current_workspace_id = $readyWorkspace->id;
+
+ OnboardingStatusUpdated::dispatchForWorkspace($readyWorkspace->id, $actor);
+
+ expect($owner->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('dispatchForWorkspace stamps completion via the account owner when the actor is null', function () {
+ Carbon::setTestNow('2026-07-29 12:00:00');
+ config(['trypost.self_hosted' => false]);
+
+ $owner = User::factory()->create(['current_workspace_id' => null]);
+ $readyWorkspace = Workspace::factory()->create([
+ 'account_id' => $owner->account_id,
+ 'user_id' => $owner->id,
+ ]);
+ subscribeAccount($owner->account);
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($owner, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $readyWorkspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $readyWorkspace->id,
+ 'user_id' => $owner->id,
+ ]));
+
+ // Webhook-style dispatch (e.g. Telegram connect): no authenticated actor.
+ OnboardingStatusUpdated::dispatchForWorkspace($readyWorkspace->id);
+
+ expect($owner->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue();
+});
+
+test('completion broadcasts exactly once per workspace', function () {
+ Event::fake([OnboardingStatusUpdated::class]);
+ config(['trypost.self_hosted' => false]);
+
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+ subscribeAccount($user->account);
+
+ AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient()));
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $workspace->id,
+ ]));
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]));
+
+ OnboardingStatusUpdated::dispatchForWorkspace($workspace->id, $user->fresh());
+
+ Event::assertDispatchedTimes(OnboardingStatusUpdated::class, 1);
+});
diff --git a/tests/Unit/Observers/OnboardingStatusBroadcastTest.php b/tests/Unit/Observers/OnboardingStatusBroadcastTest.php
new file mode 100644
index 000000000..15cf010ec
--- /dev/null
+++ b/tests/Unit/Observers/OnboardingStatusBroadcastTest.php
@@ -0,0 +1,369 @@
+create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $workspace->id,
+ ]);
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id,
+ );
+});
+
+test('deleting a social account broadcasts onboarding status for its workspace', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ $socialAccount = SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $workspace->id,
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $socialAccount->delete();
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id,
+ );
+});
+
+test('creating a post broadcasts onboarding status for its workspace', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]);
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id,
+ );
+});
+
+test('deleting a post broadcasts onboarding status for its workspace', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ $post = Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $post->delete();
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id,
+ );
+});
+
+test('creating an oauth mcp token broadcasts onboarding status for every account workspace', function () {
+ $user = User::factory()->create(['current_workspace_id' => null]);
+ $workspaceA = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspaceB = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ mcpAccessToken($user, mcpOauthClient());
+
+ Event::assertDispatchedTimes(OnboardingStatusUpdated::class, 2);
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceA->id,
+ );
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceB->id,
+ );
+});
+
+test('creating a personal access token does not broadcast onboarding status', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $user->createToken('API Key');
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('creating an oauth mcp token does not broadcast when onboarding is over', function (string $column) {
+ $user = User::factory()->create();
+ Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->account->update([$column => now()]);
+
+ mcpAccessToken($user, mcpOauthClient());
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+})->with(['onboarding_completed_at', 'onboarding_dismissed_at']);
+
+test('revoking an oauth mcp token broadcasts onboarding status', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $token = AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient()));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $token->update(['revoked' => true]);
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id,
+ );
+});
+
+test('oauth tokens bound to a workspace still broadcast onboarding status', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $token = AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient()));
+ $token->forceFill(['workspace_id' => $workspace->id])->saveQuietly();
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ (new AccessTokenObserver)->created($token->fresh());
+
+ Event::assertDispatched(
+ OnboardingStatusUpdated::class,
+ fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id,
+ );
+});
+
+test('tokens without an oauth client do not broadcast', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $token = new AccessToken([
+ 'user_id' => $user->id,
+ 'workspace_id' => $workspace->id,
+ 'revoked' => false,
+ ]);
+
+ (new AccessTokenObserver)->created($token);
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('additional posts after the first do not broadcast onboarding status', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]);
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]);
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('does not broadcast when onboarding is already completed', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->account->update(['onboarding_completed_at' => now()]);
+
+ Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]);
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('does not broadcast when onboarding is dismissed', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->account->update(['onboarding_dismissed_at' => now()]);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $workspace->id,
+ ]);
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('creating another post on the same account does not rebroadcast the step', function () {
+ $user = User::factory()->create();
+ $workspaceA = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspaceB = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $workspaceA->id,
+ 'user_id' => $user->id,
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ Post::factory()->create([
+ 'workspace_id' => $workspaceB->id,
+ 'user_id' => $user->id,
+ ]);
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('deleting a post does not broadcast while the account still has posts', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ $posts = Post::withoutEvents(fn () => Post::factory()->count(2)->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $posts->first()->delete();
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('deleting a post does not broadcast when onboarding is completed', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->account->update(['onboarding_completed_at' => now()]);
+
+ $post = Post::withoutEvents(fn () => Post::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $post->delete();
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('connecting a second social account on the account does not rebroadcast the step', function () {
+ $user = User::factory()->create();
+ $workspaceA = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspaceB = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $workspaceA->id,
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $workspaceB->id,
+ ]);
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
+
+test('deleting a social account does not broadcast while the account still has connections', function () {
+ $user = User::factory()->create();
+ $workspaceA = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspaceB = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ $socialAccounts = SocialAccount::withoutEvents(fn () => collect([
+ SocialAccount::factory()->create(['workspace_id' => $workspaceA->id]),
+ SocialAccount::factory()->create(['workspace_id' => $workspaceB->id]),
+ ]));
+
+ Event::fake([OnboardingStatusUpdated::class]);
+
+ $socialAccounts->first()->delete();
+
+ Event::assertNotDispatched(OnboardingStatusUpdated::class);
+});
diff --git a/tests/Unit/Support/Billing/CashierCheckoutEnvTest.php b/tests/Unit/Support/Billing/CashierCheckoutEnvTest.php
new file mode 100644
index 000000000..6361ebc0a
--- /dev/null
+++ b/tests/Unit/Support/Billing/CashierCheckoutEnvTest.php
@@ -0,0 +1,39 @@
+toBe(8);
+})->with([
+ 'null' => null,
+ 'empty string' => '',
+]);
+
+test('numeric trial days are parsed and floored at zero', function (mixed $value, int $expected) {
+ expect(CashierCheckoutEnv::trialDays($value))->toBe($expected);
+})->with([
+ 'eight' => ['8', 8],
+ 'zero' => ['0', 0],
+ 'integer' => [8, 8],
+ 'negative' => ['-3', 0],
+]);
+
+test('empty or null allow_promotion_codes fall back to the default', function (mixed $value) {
+ expect(CashierCheckoutEnv::allowPromotionCodes($value))->toBeTrue();
+})->with([
+ 'null' => null,
+ 'empty string' => '',
+]);
+
+test('allow_promotion_codes parses boolean-ish env strings', function (mixed $value, bool $expected) {
+ expect(CashierCheckoutEnv::allowPromotionCodes($value))->toBe($expected);
+})->with([
+ 'true string' => ['true', true],
+ 'false string' => ['false', false],
+ 'one' => ['1', true],
+ 'zero' => ['0', false],
+ 'bool true' => [true, true],
+ 'bool false' => [false, false],
+]);
diff --git a/tests/Unit/Support/Billing/CheckoutConversionDataTest.php b/tests/Unit/Support/Billing/CheckoutConversionDataTest.php
new file mode 100644
index 000000000..001e6042a
--- /dev/null
+++ b/tests/Unit/Support/Billing/CheckoutConversionDataTest.php
@@ -0,0 +1,62 @@
+ 'cs_test_123',
+ 'customer' => 'cus_abc',
+ 'amount_total' => 0,
+ 'currency' => 'usd',
+ ], 'cus_abc');
+
+ expect($payload['value'])->toBe(0)
+ ->and($payload['currency'])->toBe('USD')
+ ->and($payload['transaction_id'])->toBe('cs_test_123');
+});
+
+test('builds conversion from a discounted first-month total', function () {
+ $payload = CheckoutConversionData::fromSession([
+ 'id' => 'cs_test_456',
+ 'customer' => 'cus_abc',
+ 'amount_total' => 100,
+ 'currency' => 'usd',
+ ], 'cus_abc');
+
+ expect($payload['value'])->toBe(1)
+ ->and($payload['currency'])->toBe('USD')
+ ->and($payload['transaction_id'])->toBe('cs_test_456');
+});
+
+test('rejects sessions for a different stripe customer', function () {
+ expect(CheckoutConversionData::fromSession([
+ 'id' => 'cs_test_123',
+ 'customer' => 'cus_other',
+ 'amount_total' => 100,
+ 'currency' => 'usd',
+ ], 'cus_abc'))->toBeNull();
+});
+
+test('rejects incomplete session payloads', function (array $session) {
+ expect(CheckoutConversionData::fromSession($session, 'cus_abc'))->toBeNull();
+})->with([
+ 'missing amount' => [[
+ 'id' => 'cs_test_123',
+ 'customer' => 'cus_abc',
+ 'currency' => 'usd',
+ ]],
+ 'empty currency' => [[
+ 'id' => 'cs_test_123',
+ 'customer' => 'cus_abc',
+ 'amount_total' => 0,
+ 'currency' => '',
+ ]],
+ 'empty id' => [[
+ 'id' => '',
+ 'customer' => 'cus_abc',
+ 'amount_total' => 0,
+ 'currency' => 'usd',
+ ]],
+]);
diff --git a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php
new file mode 100644
index 000000000..a3438dc02
--- /dev/null
+++ b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php
@@ -0,0 +1,192 @@
+account = Account::factory()->create();
+ Workspace::factory()->create(['account_id' => $this->account->id]);
+
+ config([
+ 'cashier.allow_promotion_codes' => true,
+ 'trypost.billing.require_card_for_trial' => true,
+ ]);
+});
+
+function checkoutSubscription(Account $account): SubscriptionBuilder
+{
+ return $account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test');
+}
+
+function trialExpires(SubscriptionBuilder $subscription): mixed
+{
+ $property = new ReflectionProperty($subscription, 'trialExpires');
+
+ return $property->getValue($subscription);
+}
+
+test('applies trial days and promotion codes when CASHIER_TRIAL_DAYS is set', function () {
+ config(['cashier.trial_days' => 8]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription)->toDateString())
+ ->toBe(now()->addDays(8)->toDateString())
+ ->and($subscription->allowPromotionCodes)->toBeTrue()
+ ->and($subscription->couponId)->toBeNull();
+});
+
+test('clamps a one-day trial to Stripe Checkout minimum of two days', function () {
+ config(['cashier.trial_days' => 1]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription)->toDateString())
+ ->toBe(now()->addDays(ConfigureSubscriptionCheckout::MIN_CHECKOUT_TRIAL_DAYS)->toDateString());
+});
+
+test('skips promotion codes when disabled by env', function () {
+ config([
+ 'cashier.trial_days' => 8,
+ 'cashier.allow_promotion_codes' => false,
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription))->not->toBeNull()
+ ->and($subscription->allowPromotionCodes)->toBeFalse()
+ ->and($subscription->couponId)->toBeNull();
+});
+
+test('skips trial when CASHIER_TRIAL_DAYS is zero but still allows promotion codes', function () {
+ config(['cashier.trial_days' => 0]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription))->toBeNull()
+ ->and($subscription->couponId)->toBeNull()
+ ->and($subscription->allowPromotionCodes)->toBeTrue();
+});
+
+test('honors CASHIER_ALLOW_PROMOTION_CODES=false without a trial', function () {
+ config([
+ 'cashier.trial_days' => 0,
+ 'cashier.allow_promotion_codes' => false,
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription))->toBeNull()
+ ->and($subscription->couponId)->toBeNull()
+ ->and($subscription->allowPromotionCodes)->toBeFalse();
+});
+
+test('skips checkout trial for accounts with a prior canceled subscription', function () {
+ config(['cashier.trial_days' => 8]);
+
+ $this->account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'canceled',
+ 'stripe_price' => 'price_123',
+ 'ends_at' => now()->subDay(),
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription))->toBeNull()
+ ->and($subscription->allowPromotionCodes)->toBeTrue()
+ ->and(ConfigureSubscriptionCheckout::qualifiesForCheckoutTrial($this->account))->toBeFalse();
+});
+
+test('still grants a trial after an incomplete checkout attempt', function () {
+ config(['cashier.trial_days' => 8]);
+
+ $this->account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'incomplete',
+ 'stripe_price' => 'price_123',
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription)->toDateString())
+ ->toBe(now()->addDays(8)->toDateString())
+ ->and(ConfigureSubscriptionCheckout::qualifiesForCheckoutTrial($this->account))->toBeTrue();
+});
+
+test('still grants a trial after an incomplete_expired checkout attempt', function () {
+ config(['cashier.trial_days' => 8]);
+
+ $this->account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'incomplete_expired',
+ 'stripe_price' => 'price_123',
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription)->toDateString())
+ ->toBe(now()->addDays(8)->toDateString())
+ ->and(ConfigureSubscriptionCheckout::qualifiesForCheckoutTrial($this->account))->toBeTrue();
+});
+
+test('skips checkout trial when REQUIRE_CARD_FOR_TRIAL is false', function () {
+ config([
+ 'cashier.trial_days' => 8,
+ 'trypost.billing.require_card_for_trial' => false,
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription))->toBeNull()
+ ->and($subscription->allowPromotionCodes)->toBeTrue()
+ ->and(ConfigureSubscriptionCheckout::qualifiesForCheckoutTrial($this->account))->toBeFalse();
+});
+
+test('ignores non-default subscription types when deciding checkout trial eligibility', function () {
+ config(['cashier.trial_days' => 8]);
+
+ $this->account->subscriptions()->create([
+ 'type' => 'addon',
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'canceled',
+ 'stripe_price' => 'price_addon',
+ 'ends_at' => now()->subDay(),
+ ]);
+
+ $subscription = checkoutSubscription($this->account);
+
+ ConfigureSubscriptionCheckout::apply($subscription, $this->account);
+
+ expect(trialExpires($subscription)->toDateString())
+ ->toBe(now()->addDays(8)->toDateString())
+ ->and(ConfigureSubscriptionCheckout::qualifiesForCheckoutTrial($this->account))->toBeTrue();
+});
diff --git a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php b/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php
deleted file mode 100644
index 86d048ce7..000000000
--- a/tests/Unit/Support/Billing/FirstMonthCheckoutDiscountTest.php
+++ /dev/null
@@ -1,106 +0,0 @@
-account = Account::factory()->create();
-
- config([
- 'trypost.billing.require_card_for_trial' => true,
- 'cashier.first_month_coupon_id' => 'TRIAL1USD',
- ]);
-});
-
-function firstMonthSubscription(Account $account): SubscriptionBuilder
-{
- return $account->newSubscription(Account::SUBSCRIPTION_NAME, 'price_monthly_test');
-}
-
-function givePriorSubscription(Account $account, string $status = 'canceled'): void
-{
- $account->subscriptions()->create([
- 'type' => Account::SUBSCRIPTION_NAME,
- 'stripe_id' => 'sub_'.fake()->uuid(),
- 'stripe_status' => $status,
- 'stripe_price' => 'price_monthly_test',
- ]);
-}
-
-test('applies the first month coupon for a first-time single-workspace checkout', function () {
- Workspace::factory()->create(['account_id' => $this->account->id]);
-
- $subscription = firstMonthSubscription($this->account);
-
- FirstMonthCheckoutDiscount::apply($subscription, $this->account);
-
- expect($subscription->couponId)->toBe('TRIAL1USD')
- ->and($subscription->promotionCodeId)->toBeNull()
- ->and($subscription->allowPromotionCodes)->toBeFalse();
-});
-
-test('skips the coupon and allows promotion codes when a card is not required', function () {
- config(['trypost.billing.require_card_for_trial' => false]);
- Workspace::factory()->create(['account_id' => $this->account->id]);
-
- $subscription = firstMonthSubscription($this->account);
-
- FirstMonthCheckoutDiscount::apply($subscription, $this->account);
-
- expect($subscription->allowPromotionCodes)->toBeTrue()
- ->and($subscription->couponId)->toBeNull();
-});
-
-test('skips the coupon when more than one workspace is billed', function () {
- Workspace::factory()->count(2)->create(['account_id' => $this->account->id]);
-
- $subscription = firstMonthSubscription($this->account);
-
- FirstMonthCheckoutDiscount::apply($subscription, $this->account);
-
- expect($subscription->allowPromotionCodes)->toBeTrue()
- ->and($subscription->couponId)->toBeNull();
-});
-
-test('skips the coupon when the account has a prior canceled subscription', function () {
- Workspace::factory()->create(['account_id' => $this->account->id]);
- givePriorSubscription($this->account);
-
- $subscription = firstMonthSubscription($this->account);
-
- FirstMonthCheckoutDiscount::apply($subscription, $this->account);
-
- expect($subscription->allowPromotionCodes)->toBeTrue()
- ->and($subscription->couponId)->toBeNull();
-});
-
-test('still applies the coupon when the only prior subscription never left incomplete', function () {
- Workspace::factory()->create(['account_id' => $this->account->id]);
- givePriorSubscription($this->account, 'incomplete_expired');
-
- $subscription = firstMonthSubscription($this->account);
-
- FirstMonthCheckoutDiscount::apply($subscription, $this->account);
-
- expect($subscription->couponId)->toBe('TRIAL1USD')
- ->and($subscription->allowPromotionCodes)->toBeFalse();
-});
-
-test('throws instead of charging full price when a qualifying checkout has no coupon', function () {
- config(['cashier.first_month_coupon_id' => '']);
- Workspace::factory()->create(['account_id' => $this->account->id]);
-
- $subscription = firstMonthSubscription($this->account);
-
- expect(fn () => FirstMonthCheckoutDiscount::apply($subscription, $this->account))
- ->toThrow(RuntimeException::class);
-
- expect($subscription->couponId)->toBeNull();
-});