@@ -70,7 +61,6 @@ const tabs = computed(() => [
:label="$t('usage.workspaces')"
:icon="IconBuildingCommunity"
:current="usage.workspaceCount"
- :limit="usage.workspaceLimit"
tone="violet"
rotate="-rotate-2"
/>
@@ -79,7 +69,6 @@ const tabs = computed(() => [
:label="$t('usage.social_accounts')"
:icon="IconAffiliate"
:current="usage.socialAccountCount"
- :limit="usage.socialAccountLimit"
tone="amber"
rotate="rotate-1"
/>
@@ -88,7 +77,6 @@ const tabs = computed(() => [
:label="$t('usage.members')"
:icon="IconUsers"
:current="usage.memberCount"
- :limit="usage.memberLimit"
tone="emerald"
rotate="-rotate-1"
/>
diff --git a/resources/js/pages/settings/workspace/Members.vue b/resources/js/pages/settings/workspace/Members.vue
index 60b45d25..0153aeb2 100644
--- a/resources/js/pages/settings/workspace/Members.vue
+++ b/resources/js/pages/settings/workspace/Members.vue
@@ -62,7 +62,7 @@ const tabs = computed(() => [
-
+
diff --git a/routes/app.php b/routes/app.php
index 875748dc..01253dc2 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\NotificationController;
+use App\Http\Controllers\App\OnboardingController;
use App\Http\Controllers\App\PostAiCreateController;
use App\Http\Controllers\App\PostAiGenerateController;
use App\Http\Controllers\App\PostAiRegenerateMediaController;
@@ -55,7 +56,10 @@
})->name('app.home');
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
- Route::post('billing/checkout/{plan}', [BillingController::class, 'checkout'])->name('app.billing.checkout');
+ Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
+ Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.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('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');
@@ -125,6 +129,11 @@
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback');
+
+ // Disconnecting must also work during onboarding (before a subscription
+ // exists), so it lives here rather than behind EnsureAccountReady — the
+ // controller still authorizes workspace ownership.
+ Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
});
// Routes that require active subscription and completed onboarding
@@ -154,7 +163,6 @@
// Social Accounts
Route::get('accounts', [SocialController::class, 'index'])->name('app.accounts');
- Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
Route::put('accounts/{account}/toggle', [SocialController::class, 'toggleActive'])->name('app.accounts.toggle');
// Analytics
@@ -254,7 +262,7 @@
// Billing
Route::get('settings/account/billing', [BillingController::class, 'index'])->name('app.billing.index');
Route::get('settings/account/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal');
- Route::post('settings/account/billing/swap/{plan}', [BillingController::class, 'swap'])->name('app.billing.swap');
+ Route::post('settings/account/billing/swap-to-yearly', [BillingController::class, 'swapToYearly'])->name('app.billing.swap-to-yearly');
});
diff --git a/tests/Feature/AcceptInviteControllerTest.php b/tests/Feature/AcceptInviteControllerTest.php
index 6d78840d..d1cb85aa 100644
--- a/tests/Feature/AcceptInviteControllerTest.php
+++ b/tests/Feature/AcceptInviteControllerTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
@@ -130,6 +131,33 @@
expect($invite->accepted_at)->not->toBeNull();
});
+test('accept invite assigns the exact role from the invite', function (Role $role) {
+ $user = User::factory()->create([
+ 'email' => 'invitee@example.com',
+ ]);
+
+ $invite = Invite::factory()->create([
+ 'account_id' => $this->account->id,
+ 'invited_by' => $this->owner->id,
+ 'email' => 'invitee@example.com',
+ 'workspaces' => [$this->workspace->id],
+ 'role' => $role,
+ ]);
+
+ $this->actingAs($user)
+ ->post(route('app.invites.accept', $invite))
+ ->assertRedirect(route('app.calendar'));
+
+ $member = $this->workspace->members()->where('user_id', $user->id)->first();
+
+ expect($member)->not->toBeNull();
+ expect($member->pivot->role)->toBe($role->value);
+})->with([
+ 'viewer' => Role::Viewer,
+ 'admin' => Role::Admin,
+ 'member' => Role::Member,
+]);
+
test('accept invite fails for wrong email', function () {
$user = User::factory()->create([
'email' => 'different@example.com',
diff --git a/tests/Feature/Actions/User/CreateUserTest.php b/tests/Feature/Actions/User/CreateUserTest.php
index 353f47cc..570ea539 100644
--- a/tests/Feature/Actions/User/CreateUserTest.php
+++ b/tests/Feature/Actions/User/CreateUserTest.php
@@ -8,7 +8,7 @@
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
-test('CreateUser creates user with account but no workspace and no current_workspace_id', function () {
+test('CreateUser creates the owner a default workspace and sets it as current', function () {
$user = CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane@example.com',
@@ -17,10 +17,14 @@
expect($user->name)->toBe('Jane Doe');
expect($user->email)->toBe('jane@example.com');
- expect($user->current_workspace_id)->toBeNull();
expect($user->account_id)->not->toBeNull();
- expect(Workspace::count())->toBe(0);
expect(Account::find($user->account_id))->not->toBeNull();
+
+ $workspace = $user->workspaces()->first();
+ expect($user->workspaces()->count())->toBe(1);
+ expect($workspace->name)->toBe("Jane Doe's Workspace");
+ expect($workspace->account_id)->toBe($user->account_id);
+ expect($user->fresh()->current_workspace_id)->toBe($workspace->id);
});
test('CreateUser sets account owner_id to the new user', function () {
diff --git a/tests/Feature/AiUsageLogTest.php b/tests/Feature/AiUsageLogTest.php
index 4872839a..9785b60b 100644
--- a/tests/Feature/AiUsageLogTest.php
+++ b/tests/Feature/AiUsageLogTest.php
@@ -5,43 +5,59 @@
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Workspace;
+use Illuminate\Support\Carbon;
-test('monthly credits returns sum of credits for account this month', function () {
+test('credits used between sums credits within the window', function () {
$account = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
AiUsageLog::factory()->text(credits: 5)->count(3)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
+ 'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 10)->count(2)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
+ 'created_at' => Carbon::parse('2026-06-12'),
]);
- expect(AiUsageLog::monthlyCredits($account->id))->toBe(35);
+ $used = AiUsageLog::creditsUsedBetween(
+ $account->id,
+ Carbon::parse('2026-06-01'),
+ Carbon::parse('2026-07-01'),
+ );
+
+ expect($used)->toBe(35);
});
-test('monthly credits excludes logs from other months', function () {
+test('credits used between excludes logs outside the window', function () {
$account = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
+ 'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 50)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
- 'created_at' => now()->subMonth(),
+ 'created_at' => Carbon::parse('2026-05-10'),
]);
- expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
+ $used = AiUsageLog::creditsUsedBetween(
+ $account->id,
+ Carbon::parse('2026-06-01'),
+ Carbon::parse('2026-07-01'),
+ );
+
+ expect($used)->toBe(10);
});
-test('monthly credits excludes logs from other accounts', function () {
+test('credits used between excludes logs from other accounts', function () {
$account = Account::factory()->create();
$otherAccount = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
@@ -50,12 +66,20 @@
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
+ 'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 100)->create([
'account_id' => $otherAccount->id,
'workspace_id' => $otherWorkspace->id,
+ 'created_at' => Carbon::parse('2026-06-10'),
]);
- expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
+ $used = AiUsageLog::creditsUsedBetween(
+ $account->id,
+ Carbon::parse('2026-06-01'),
+ Carbon::parse('2026-07-01'),
+ );
+
+ expect($used)->toBe(10);
});
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
index 495791ca..0c36aa60 100644
--- a/tests/Feature/Auth/RegistrationTest.php
+++ b/tests/Feature/Auth/RegistrationTest.php
@@ -24,7 +24,7 @@
$response->assertRedirect(route('register.success', absolute: false));
});
-test('new users do not get a default workspace on registration', function () {
+test('new users get a default workspace on registration', function () {
$this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
@@ -35,22 +35,9 @@
expect($user)->not->toBeNull();
expect($user->account_id)->not->toBeNull();
- expect($user->workspaces()->count())->toBe(0);
- expect($user->current_workspace_id)->toBeNull();
-});
-
-test('new users can register with a timezone preference', function () {
- $this->post(route('register.store'), [
- 'name' => 'Test User',
- 'email' => 'test@example.com',
- 'password' => 'Password123!',
- 'timezone' => 'America/Sao_Paulo',
- ]);
-
- $user = User::where('email', 'test@example.com')->first();
-
- expect($user)->not->toBeNull();
- expect($user->workspaces()->count())->toBe(0);
+ expect($user->workspaces()->count())->toBe(1);
+ expect($user->workspaces()->first()->name)->toBe("Test User's Workspace");
+ expect($user->current_workspace_id)->toBe($user->workspaces()->first()->id);
});
test('new users do not have verified email by default', function () {
@@ -65,42 +52,6 @@
expect($user->email_verified_at)->toBeNull();
});
-test('new users can register with deprecated timezone Asia/Calcutta', function () {
- $response = $this->post(route('register.store'), [
- 'name' => 'Test User',
- 'email' => 'test@example.com',
- 'password' => 'Password123!',
- 'timezone' => 'Asia/Calcutta',
- ]);
-
- $response->assertSessionHasNoErrors();
- expect(User::where('email', 'test@example.com')->exists())->toBeTrue();
-});
-
-test('new users can register with deprecated timezone US/Eastern', function () {
- $response = $this->post(route('register.store'), [
- 'name' => 'Test User',
- 'email' => 'test@example.com',
- 'password' => 'Password123!',
- 'timezone' => 'US/Eastern',
- ]);
-
- $response->assertSessionHasNoErrors();
- expect(User::where('email', 'test@example.com')->exists())->toBeTrue();
-});
-
-test('new users cannot register with invalid timezone', function () {
- $response = $this->post(route('register.store'), [
- 'name' => 'Test User',
- 'email' => 'test@example.com',
- 'password' => 'Password123!',
- 'timezone' => 'Invalid/Timezone',
- ]);
-
- $response->assertSessionHasErrors('timezone');
- expect(User::where('email', 'test@example.com')->exists())->toBeFalse();
-});
-
test('new users registering via invite have verified email automatically', function () {
$this->post(route('register.store'), [
'name' => 'Test User',
diff --git a/tests/Feature/Auth/SignupRequiresCheckoutTest.php b/tests/Feature/Auth/SignupRequiresCheckoutTest.php
index 005c0e02..c89bbc73 100644
--- a/tests/Feature/Auth/SignupRequiresCheckoutTest.php
+++ b/tests/Feature/Auth/SignupRequiresCheckoutTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Actions\User\CreateUser;
+use App\Enums\Plan\Slug;
use Database\Seeders\PlanSeeder;
beforeEach(function () {
@@ -16,7 +17,6 @@
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'password123',
- 'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
@@ -32,10 +32,9 @@
'name' => 'Alice',
'email' => 'alice+nocard@example.com',
'password' => 'password123',
- 'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
- expect($user->account->plan_id)->not->toBeNull();
+ expect($user->account->plan->slug)->toBe(Slug::Workspace);
expect($user->account->trial_ends_at)->not->toBeNull();
});
diff --git a/tests/Feature/Billing/BillingCycleTest.php b/tests/Feature/Billing/BillingCycleTest.php
new file mode 100644
index 00000000..c4ed4232
--- /dev/null
+++ b/tests/Feature/Billing/BillingCycleTest.php
@@ -0,0 +1,123 @@
+creditAllotment())->toBe(7500);
+});
+
+test('yearly allotment grants twelve months upfront per workspace', function () {
+ $account = billingAccount('price_year', workspaces: 2);
+
+ expect(BillingCycle::for($account)->creditAllotment())->toBe(60000);
+});
+
+test('during trial the allotment is the monthly amount even on a yearly price', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+
+ $account = billingAccount('price_year', [
+ 'created_at' => Carbon::parse('2026-06-18'),
+ 'trial_ends_at' => Carbon::parse('2026-06-25'),
+ ], workspaces: 1);
+
+ expect(BillingCycle::for($account)->creditAllotment())->toBe(2500);
+});
+
+test('during trial the window spans the subscription creation to the trial end', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+
+ $account = billingAccount('price_year', [
+ 'created_at' => Carbon::parse('2026-06-18'),
+ 'trial_ends_at' => Carbon::parse('2026-06-25'),
+ ], workspaces: 1);
+
+ $cycle = BillingCycle::for($account);
+
+ expect($cycle->periodStart()->toDateString())->toBe('2026-06-18')
+ ->and($cycle->periodEnd()->toDateString())->toBe('2026-06-25');
+});
+
+test('monthly cycle window spans the anchor day to the next month', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+ $account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]);
+
+ $cycle = BillingCycle::for($account);
+
+ expect($cycle->periodStart()->toDateString())->toBe('2026-06-05')
+ ->and($cycle->periodEnd()->toDateString())->toBe('2026-07-05');
+});
+
+test('yearly cycle window spans one year from the anchor', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+ $account = billingAccount('price_year', ['created_at' => Carbon::parse('2025-02-10')]);
+
+ $cycle = BillingCycle::for($account);
+
+ expect($cycle->periodStart()->toDateString())->toBe('2026-02-10')
+ ->and($cycle->periodEnd()->toDateString())->toBe('2027-02-10');
+});
+
+test('used credits counts only usage within the current cycle window', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+
+ $account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]);
+ $workspace = $account->workspaces()->first();
+
+ AiUsageLog::factory()->text(credits: 10)->create([
+ 'account_id' => $account->id,
+ 'workspace_id' => $workspace->id,
+ 'created_at' => Carbon::parse('2026-06-10'),
+ ]);
+
+ AiUsageLog::factory()->text(credits: 99)->create([
+ 'account_id' => $account->id,
+ 'workspace_id' => $workspace->id,
+ 'created_at' => Carbon::parse('2026-06-01'),
+ ]);
+
+ expect(BillingCycle::for($account)->usedCredits())->toBe(10);
+});
+
+test('without a subscription the allotment falls back to a monthly amount', function () {
+ $plan = Plan::query()->firstOrFail();
+ $account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
+ Workspace::factory()->count(2)->create(['account_id' => $account->id]);
+
+ expect(BillingCycle::for($account)->creditAllotment())->toBe(5000);
+});
+
+test('monthly window clamps an end-of-month anchor without drift', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+ $account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-01-31')]);
+
+ $cycle = BillingCycle::for($account);
+
+ expect($cycle->periodStart()->toDateString())->toBe('2026-05-31')
+ ->and($cycle->periodEnd()->toDateString())->toBe('2026-06-30');
+});
+
+test('yearly window clamps a leap-day anchor without drift', function () {
+ Carbon::setTestNow('2026-06-20 12:00:00');
+ $account = billingAccount('price_year', ['created_at' => Carbon::parse('2024-02-29')]);
+
+ $cycle = BillingCycle::for($account);
+
+ expect($cycle->periodStart()->toDateString())->toBe('2026-02-28')
+ ->and($cycle->periodEnd()->toDateString())->toBe('2027-02-28');
+});
+
+test('allotment is zero when the account has no plan', function () {
+ $account = Account::factory()->create(['plan_id' => null, 'trial_ends_at' => null]);
+ Workspace::factory()->count(2)->create(['account_id' => $account->id]);
+
+ expect(BillingCycle::for($account)->creditAllotment())->toBe(0);
+});
diff --git a/tests/Feature/Billing/TrialLengthTest.php b/tests/Feature/Billing/TrialLengthTest.php
new file mode 100644
index 00000000..cbd3a0fd
--- /dev/null
+++ b/tests/Feature/Billing/TrialLengthTest.php
@@ -0,0 +1,22 @@
+toBe(8);
+});
+
+test('signup grants a trial of cashier.trial_days in no-card mode', function () {
+ config(['trypost.billing.require_card_for_trial' => false]);
+
+ $user = CreateUser::execute([
+ 'name' => 'Trial User',
+ 'email' => 'trial@example.com',
+ 'password' => 'secret123',
+ ]);
+
+ expect($user->account->trial_ends_at->toDateString())
+ ->toBe(now()->addDays(config('cashier.trial_days'))->toDateString());
+});
diff --git a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php
new file mode 100644
index 00000000..8f2bae64
--- /dev/null
+++ b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php
@@ -0,0 +1,75 @@
+set('trypost.self_hosted', true);
+
+ $subscription = mock(Subscription::class);
+ $subscription->shouldReceive('active')->andReturnTrue();
+ $subscription->shouldReceive('updateQuantity')->never();
+
+ $account = mock(Account::class)->makePartial();
+ $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
+ $account->shouldReceive('workspaces->count')->andReturn(3);
+
+ $account->syncWorkspaceQuantity();
+});
+
+test('syncWorkspaceQuantity does not touch Stripe without an active subscription', function () {
+ config()->set('trypost.self_hosted', false);
+
+ $subscription = mock(Subscription::class);
+ $subscription->shouldReceive('active')->andReturnFalse();
+ $subscription->shouldReceive('updateQuantity')->never();
+
+ $account = mock(Account::class)->makePartial();
+ $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
+
+ $account->syncWorkspaceQuantity();
+});
+
+test('syncWorkspaceQuantity updates the subscription quantity to the workspace count', function () {
+ config()->set('trypost.self_hosted', false);
+
+ $subscription = mock(Subscription::class);
+ $subscription->shouldReceive('active')->andReturnTrue();
+ $subscription->shouldReceive('updateQuantity')->once()->with(3);
+
+ $account = mock(Account::class)->makePartial();
+ $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
+ $account->shouldReceive('workspaces->count')->andReturn(3);
+
+ $account->syncWorkspaceQuantity();
+});
+
+test('creating a workspace syncs the stripe quantity', function () {
+ $user = User::factory()->create();
+
+ $account = mock(Account::class)->makePartial();
+ $account->shouldReceive('syncWorkspaceQuantity')->once();
+ $user->setRelation('account', $account);
+
+ CreateWorkspace::execute($user, ['name' => 'Wiring']);
+});
+
+test('deleting a workspace syncs the stripe quantity', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+
+ $account = mock(Account::class)->makePartial();
+ $account->shouldReceive('syncWorkspaceQuantity')->once();
+ $workspace->setRelation('account', $account);
+
+ DeleteWorkspace::execute($user, $workspace);
+});
diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php
index ec6d7b0a..d3cea0ed 100644
--- a/tests/Feature/BillingControllerTest.php
+++ b/tests/Feature/BillingControllerTest.php
@@ -31,37 +31,27 @@
$response->assertRedirect(route('login'));
});
-test('subscribe shows subscription page', function () {
+test('subscribe redirects to onboarding', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('billing/Subscribe', false)
- ->has('plans')
- );
+ $response->assertRedirect(route('app.onboarding'));
});
-test('subscribe redirects to billing index when account has active subscription', function () {
- config(['trypost.self_hosted' => false]);
-
- $this->account->subscriptions()->create([
- 'type' => Account::SUBSCRIPTION_NAME,
- 'stripe_id' => 'sub_test_'.fake()->uuid(),
- 'stripe_status' => 'active',
- 'stripe_price' => 'price_123',
- ]);
-
- $response = $this->actingAs($this->user)->get(route('app.subscribe'));
-
- $response->assertRedirect(route('app.billing.index'));
-});
-
-test('subscribe redirects to calendar in self hosted mode', function () {
+test('swapToYearly redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
- $response = $this->actingAs($this->user)->get(route('app.subscribe'));
+ $response = $this->actingAs($this->user)
+ ->post(route('app.billing.swap-to-yearly'));
+
+ $response->assertRedirect(route('app.calendar'));
+});
+
+test('portal redirects to calendar in self hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $response = $this->actingAs($this->user)->get(route('app.billing.portal'));
$response->assertRedirect(route('app.calendar'));
});
@@ -90,7 +80,7 @@
->component('settings/account/Billing', false)
->has('hasSubscription')
->has('plan')
- ->has('plans')
+ ->has('workspaceCount')
);
});
@@ -134,31 +124,6 @@
);
});
-test('subscribe page exposes trialDays prop', function () {
- config(['trypost.self_hosted' => false]);
-
- $response = $this->actingAs($this->user)->get(route('app.subscribe'));
-
- $response->assertInertia(fn ($page) => $page
- ->component('billing/Subscribe', false)
- ->where('trialDays', config('cashier.trial_days'))
- );
-});
-
-test('subscribe page exposes null trialDays when card is not required', function () {
- config([
- 'trypost.self_hosted' => false,
- 'trypost.billing.require_card_for_trial' => false,
- ]);
-
- $response = $this->actingAs($this->user)->get(route('app.subscribe'));
-
- $response->assertInertia(fn ($page) => $page
- ->component('billing/Subscribe', false)
- ->where('trialDays', null)
- );
-});
-
test('billing index redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
@@ -230,15 +195,15 @@
test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () {
config(['trypost.self_hosted' => false]);
- $plan = Plan::where('slug', 'pro')->firstOrFail();
+ $plan = Plan::where('slug', 'workspace')->firstOrFail();
$this->account->update(['plan_id' => $plan->id]);
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.processing'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
- ->where('auth.plan.name', 'Pro')
- ->where('auth.plan.slug', 'pro')
+ ->where('auth.plan.name', 'Workspace')
+ ->where('auth.plan.slug', 'workspace')
->where('auth.plan.interval', 'monthly')
);
});
@@ -252,13 +217,6 @@
});
// Checkout tests
-test('checkout requires authentication', function () {
- $plan = Plan::first();
- $response = $this->post(route('app.billing.checkout', $plan));
-
- $response->assertRedirect(route('login'));
-});
-
// Portal tests
test('portal requires authentication', function () {
$response = $this->get(route('app.billing.portal'));
@@ -305,81 +263,51 @@
$this->actingAs($member)->get(route('app.billing.index'))->assertForbidden();
});
-// Swap tests
-test('swap blocks downgrade when usage exceeds target plan limits', function () {
+// Swap-to-yearly tests
+test('swapToYearly forbids a non-owner', function () {
config(['trypost.self_hosted' => false]);
- $currentPlan = Plan::where('slug', 'plus')->first();
- $currentPlan->update([
- 'stripe_monthly_price_id' => 'price_current_monthly',
- 'stripe_yearly_price_id' => 'price_current_yearly',
- ]);
- $this->account->update(['plan_id' => $currentPlan->id]);
+ $member = User::factory()->create(['account_id' => $this->account->id]);
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+ $member->update(['current_workspace_id' => $this->workspace->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
- 'stripe_price' => 'price_current_monthly',
+ 'stripe_price' => 'price_monthly',
]);
- Workspace::factory()->count(3)->create([
- 'account_id' => $this->account->id,
- 'user_id' => $this->user->id,
- ]);
-
- // Starter: workspace_limit=1
- $targetPlan = Plan::where('slug', 'starter')->first();
- $targetPlan->update([
- 'stripe_monthly_price_id' => 'price_target_monthly',
- 'stripe_yearly_price_id' => 'price_target_yearly',
- ]);
-
- $this->user->unsetRelation('account');
-
- $response = $this->actingAs($this->user)
- ->from(route('app.billing.index'))
- ->post(route('app.billing.swap', $targetPlan), [
- 'price_id' => 'price_target_monthly',
- ]);
-
- $response->assertRedirect(route('app.billing.index'));
- $response->assertSessionHas('flash.error', __('billing.flash.cannot_downgrade.workspaces', [
- 'plan' => $targetPlan->name,
- 'count' => '4', // 3 created + 1 from beforeEach
- 'limit' => '1',
- ]));
+ $this->actingAs($member)
+ ->post(route('app.billing.swap-to-yearly'))
+ ->assertForbidden();
});
-test('swap rejects invalid price_id for plan', function () {
+test('swapToYearly is a no-op when already on annual billing', function () {
config(['trypost.self_hosted' => false]);
- $plan = Plan::where('slug', 'pro')->first();
+ $plan = Plan::where('slug', 'workspace')->first();
$plan->update([
'stripe_monthly_price_id' => 'price_monthly',
'stripe_yearly_price_id' => 'price_yearly',
]);
+ $this->account->update(['plan_id' => $plan->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
- 'stripe_price' => 'price_other',
+ 'stripe_price' => 'price_yearly',
]);
-
$this->user->unsetRelation('account');
- $response = $this->actingAs($this->user)
- ->post(route('app.billing.swap', $plan), [
- 'price_id' => 'price_unrelated',
- ]);
-
- $response->assertStatus(422);
+ $this->actingAs($this->user)
+ ->post(route('app.billing.swap-to-yearly'))
+ ->assertRedirect(route('app.billing.index'));
});
-test('swap requires authentication', function () {
- $plan = Plan::first();
- $response = $this->post(route('app.billing.swap', $plan));
+test('swapToYearly requires authentication', function () {
+ $response = $this->post(route('app.billing.swap-to-yearly'));
$response->assertRedirect(route('login'));
});
diff --git a/tests/Feature/InvitePermissionTest.php b/tests/Feature/InvitePermissionTest.php
new file mode 100644
index 00000000..c0cfb6e0
--- /dev/null
+++ b/tests/Feature/InvitePermissionTest.php
@@ -0,0 +1,43 @@
+ false]);
+
+ $this->account = Account::factory()->create();
+ $this->user = User::factory()->create([
+ 'account_id' => $this->account->id,
+ ]);
+ $this->account->update(['owner_id' => $this->user->id]);
+ $this->workspace = Workspace::factory()->create([
+ 'account_id' => $this->account->id,
+ 'user_id' => $this->user->id,
+ ]);
+ $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+});
+
+test('owner can invite members regardless of count', function () {
+ $members = User::factory()->count(10)->create([
+ 'account_id' => $this->account->id,
+ ]);
+
+ foreach ($members as $member) {
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+ }
+
+ expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
+});
+
+test('a non-admin member cannot invite members', function () {
+ $member = User::factory()->create(['account_id' => $this->account->id]);
+ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
+
+ expect($member->can('inviteMember', $this->workspace))->toBeFalse();
+});
diff --git a/tests/Feature/Jobs/PostHog/TrackBillingTest.php b/tests/Feature/Jobs/PostHog/TrackBillingTest.php
index 5e0e941b..5a6d0ecb 100644
--- a/tests/Feature/Jobs/PostHog/TrackBillingTest.php
+++ b/tests/Feature/Jobs/PostHog/TrackBillingTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\PostHog\BillingEvent;
+use App\Enums\User\Persona;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncUser;
use App\Jobs\PostHog\TrackBilling;
@@ -50,6 +51,19 @@
});
});
+test('handle forwards the owner persona as an event property', function () {
+ $this->user->update(['persona' => Persona::Agency->value]);
+ Queue::fake();
+
+ (new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
+ ->handle(app(PostHogService::class));
+
+ Queue::assertPushed(
+ SendEvent::class,
+ fn ($job) => ($job->payload['properties']['persona'] ?? null) === Persona::Agency->value,
+ );
+});
+
test('handle forwards previousPlan as a property when supplied', function () {
Queue::fake();
diff --git a/tests/Feature/LimitEnforcementTest.php b/tests/Feature/LimitEnforcementTest.php
deleted file mode 100644
index 8342931b..00000000
--- a/tests/Feature/LimitEnforcementTest.php
+++ /dev/null
@@ -1,73 +0,0 @@
- false]);
-
- $this->plan = Plan::first();
- $this->plan->update([
- 'workspace_limit' => 5,
- 'member_limit' => 5,
- ]);
-
- $this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
- $this->user = User::factory()->create([
- 'account_id' => $this->account->id,
- ]);
- $this->account->update(['owner_id' => $this->user->id]);
- $this->workspace = Workspace::factory()->create([
- 'account_id' => $this->account->id,
- 'user_id' => $this->user->id,
- ]);
- $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
- $this->user->update(['current_workspace_id' => $this->workspace->id]);
-});
-
-test('can invite member within limit', function () {
- expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
-});
-
-test('cannot invite member beyond limit', function () {
- $members = User::factory()->count(4)->create([
- 'account_id' => $this->account->id,
- ]);
-
- foreach ($members as $member) {
- $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
- }
-
- // 1 owner + 4 members = 5, which is the limit
- expect($this->user->can('inviteMember', $this->workspace))->toBeFalse();
-});
-
-test('self hosted mode bypasses workspace limit', function () {
- config(['trypost.self_hosted' => true]);
-
- Workspace::factory()->count(10)->create([
- 'account_id' => $this->account->id,
- 'user_id' => $this->user->id,
- ]);
-
- expect($this->user->can('create', Workspace::class))->toBeTrue();
-});
-
-test('self hosted mode bypasses member limit', function () {
- config(['trypost.self_hosted' => true]);
-
- $members = User::factory()->count(10)->create([
- 'account_id' => $this->account->id,
- ]);
-
- foreach ($members as $member) {
- $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
- }
-
- expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
-});
diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php
index ced8ebe9..a3f71f5c 100644
--- a/tests/Feature/Listeners/StripeEventListenerTest.php
+++ b/tests/Feature/Listeners/StripeEventListenerTest.php
@@ -2,21 +2,32 @@
declare(strict_types=1);
+use App\Enums\Plan\Slug;
use App\Enums\PostHog\BillingEvent;
-use App\Features\WorkspaceLimit;
use App\Jobs\PostHog\TrackBilling;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
-use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Events\WebhookReceived;
-use Laravel\Pennant\Feature;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
+ Plan::factory()->create([
+ 'slug' => Slug::Starter,
+ 'name' => 'Starter',
+ 'stripe_monthly_price_id' => 'price_starter_monthly',
+ 'stripe_yearly_price_id' => 'price_starter_yearly',
+ ]);
+ Plan::factory()->create([
+ 'slug' => Slug::Pro,
+ 'name' => 'Pro',
+ 'stripe_monthly_price_id' => 'price_pro_monthly',
+ 'stripe_yearly_price_id' => 'price_pro_yearly',
+ ]);
+
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
$this->user = User::factory()->create([
'account_id' => $this->account->id,
@@ -422,22 +433,15 @@
});
// ========================================
-// Pennant feature cache invalidation
+// Plan mapping by Stripe price id
// ========================================
-test('subscription updated flushes the pennant cache when the plan changes', function () {
+test('subscription updated maps the plan by price id', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
- // Prime the Pennant cache against the starter limit.
- expect(Feature::for($this->account)->value(WorkspaceLimit::class))
- ->toBe($starter->workspace_limit);
-
- expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
- ->toBeGreaterThan(0);
-
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
@@ -446,27 +450,32 @@
]],
]));
- expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
- ->toBe(0);
-
- expect(Feature::for($this->account->fresh())->value(WorkspaceLimit::class))
- ->toBe($pro->workspace_limit);
+ expect($this->account->fresh()->plan_id)->toBe($pro->id);
});
-test('subscription deleted flushes the pennant cache', function () {
+test('subscription updated maps an archived legacy plan by price id', function () {
+ $pro = Plan::query()->where('slug', 'pro')->firstOrFail();
+ $pro->update(['is_archived' => true]);
+
+ $this->listener->handle(new WebhookReceived([
+ 'type' => 'customer.subscription.updated',
+ 'data' => ['object' => [
+ 'customer' => 'cus_test123',
+ 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]],
+ ]],
+ ]));
+
+ expect($this->account->fresh()->plan_id)->toBe($pro->id);
+});
+
+test('subscription deleted clears the plan_id', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
- Feature::for($this->account)->value(WorkspaceLimit::class);
-
- expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
- ->toBeGreaterThan(0);
-
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123']],
]));
- expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
- ->toBe(0);
+ expect($this->account->fresh()->plan_id)->toBeNull();
});
diff --git a/tests/Feature/Middleware/EnsureAccountReadyTest.php b/tests/Feature/Middleware/EnsureAccountReadyTest.php
index 2e416038..d48f22e4 100644
--- a/tests/Feature/Middleware/EnsureAccountReadyTest.php
+++ b/tests/Feature/Middleware/EnsureAccountReadyTest.php
@@ -11,13 +11,13 @@
config()->set('trypost.self_hosted', false);
});
-test('redirects to subscribe when account has no active subscription', function () {
+test('redirects to onboarding 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.subscribe'));
+ ->assertRedirect(route('app.onboarding'));
});
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 4a552a0b..5969a0ed 100644
--- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
+++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php
@@ -20,7 +20,6 @@
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'password123',
- 'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
@@ -33,7 +32,7 @@
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
- $response->assertRedirect(route('app.subscribe'));
+ $response->assertRedirect(route('app.onboarding'));
});
test('user with active subscription can access the app', function () {
@@ -41,7 +40,6 @@
'name' => 'Alice',
'email' => 'alice2@example.com',
'password' => 'password123',
- 'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
@@ -127,7 +125,6 @@
'name' => 'Alice',
'email' => 'alice-generic@example.com',
'password' => 'password123',
- 'timezone' => 'UTC',
'registration_ip' => '127.0.0.1',
]);
diff --git a/tests/Feature/Models/AccountModelTest.php b/tests/Feature/Models/AccountModelTest.php
deleted file mode 100644
index f0c3a4e0..00000000
--- a/tests/Feature/Models/AccountModelTest.php
+++ /dev/null
@@ -1,41 +0,0 @@
-first();
- $plus = Plan::where('slug', 'plus')->first();
-
- $account = Account::factory()->create(['plan_id' => $starter->id]);
-
- // Prime the Pennant cache against the starter plan.
- Feature::for($account)->value(WorkspaceLimit::class);
- Feature::for($account)->value(SocialAccountLimit::class);
- Feature::for($account)->value(MemberLimit::class);
- Feature::for($account)->value(MonthlyCreditsLimit::class);
-
- expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
- ->toBe(4);
-
- // Move the account to a plan with different limits and forget the cache.
- $account->update(['plan_id' => $plus->id]);
- $account->forgetPlanFeatureCache();
- $account->load('plan');
-
- expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
- ->toBe(0);
-
- expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($plus->workspace_limit);
- expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($plus->social_account_limit);
- expect(Feature::for($account)->value(MemberLimit::class))->toBe($plus->member_limit);
- expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($plus->monthly_credits_limit);
-});
diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php
index 502aae3e..3a5cdf55 100644
--- a/tests/Feature/Models/HasUsageTraitTest.php
+++ b/tests/Feature/Models/HasUsageTraitTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Enums\Plan\Slug;
use App\Models\Account;
use App\Models\Invite;
use App\Models\Plan;
@@ -43,17 +44,19 @@
]);
});
-test('featureLimits returns plan-resolved limits', function () {
- $plan = Plan::where('slug', 'plus')->first();
+test('featureLimits returns the monthly credits allotment', function () {
+ $plan = Plan::where('slug', Slug::Workspace)->first();
$this->account->update(['plan_id' => $plan->id]);
+ Workspace::factory()->count(2)->create([
+ 'account_id' => $this->account->id,
+ 'user_id' => $this->owner->id,
+ ]);
+
$limits = $this->account->featureLimits();
expect($limits)->toBe([
- 'workspaceLimit' => $plan->workspace_limit,
- 'socialAccountLimit' => $plan->social_account_limit,
- 'memberLimit' => $plan->member_limit,
- 'monthlyCreditsLimit' => $plan->monthly_credits_limit,
+ 'monthlyCreditsLimit' => 5000,
]);
});
diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php
new file mode 100644
index 00000000..ba489b97
--- /dev/null
+++ b/tests/Feature/Onboarding/OnboardingControllerTest.php
@@ -0,0 +1,232 @@
+ 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',
+ ]);
+}
+
+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'));
+});
+
+test('onboarding store rejects an invalid persona', function () {
+ $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
+ 'persona' => 'not-a-persona',
+ ]);
+
+ $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');
+});
+
+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,
+ ]);
+
+ $response->assertRedirect(route('app.calendar'));
+ expect($this->user->fresh()->persona)->toBeNull();
+});
+
+test('onboarding store saves the persona, mirrors to PostHog and advances to the connect 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,
+ ]);
+
+ $response->assertRedirect(route('app.onboarding.connect'));
+ expect($this->user->fresh()->persona)->toBe(Persona::Agency);
+
+ Bus::assertDispatched(SendEvent::class);
+});
+
+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,
+ ]);
+
+ $response->assertRedirect(route('app.calendar'));
+ expect($this->user->fresh()->persona)->toBeNull();
+});
+
+test('connect renders the network grid for an unsubscribed account that picked a persona', function () {
+ $this->user->update(['persona' => Persona::Agency->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 lists the workspace social accounts already connected', function () {
+ $this->user->update(['persona' => Persona::Agency->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)
+ );
+});
+
+test('connect redirects back to the persona step when no persona was chosen', function () {
+ onboardingWorkspace($this->user);
+
+ $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
+
+ $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]);
+ 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]);
+ $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',
+ ]);
+
+ $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'));
+
+ $response->assertRedirect(route('app.calendar'));
+});
+
+test('checkout does nothing in self-hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $this->mock(StartSubscriptionCheckout::class)
+ ->shouldReceive('redirect')
+ ->never();
+
+ $response = $this->actingAs($this->user)->post(route('app.onboarding.checkout'));
+
+ $response->assertRedirect(route('app.calendar'));
+});
diff --git a/tests/Feature/PlanSeederTest.php b/tests/Feature/PlanSeederTest.php
index f10b630c..37167fc3 100644
--- a/tests/Feature/PlanSeederTest.php
+++ b/tests/Feature/PlanSeederTest.php
@@ -6,40 +6,23 @@
use App\Models\Plan;
use Database\Seeders\PlanSeeder;
-test('seeder creates 4 plans', function () {
- expect(Plan::count())->toBe(4);
-});
+test('seeder creates only the per-workspace plan', function () {
+ expect(Plan::count())->toBe(1);
-test('seeder creates plans with correct limits', function () {
- $starter = Plan::where('slug', Slug::Starter)->first();
+ $workspace = Plan::where('slug', Slug::Workspace)->first();
- expect($starter->name)->toBe('Starter')
- ->and($starter->social_account_limit)->toBe(5)
- ->and($starter->member_limit)->toBe(1)
- ->and($starter->workspace_limit)->toBe(1)
- ->and($starter->monthly_credits_limit)->toBe(1000)
- ->and($starter->sort)->toBe(1);
-
- $max = Plan::where('slug', Slug::Max)->first();
-
- expect($max->name)->toBe('Max')
- ->and($max->social_account_limit)->toBe(100)
- ->and($max->member_limit)->toBe(20)
- ->and($max->workspace_limit)->toBe(50)
- ->and($max->monthly_credits_limit)->toBe(15000)
- ->and($max->sort)->toBe(4);
+ expect($workspace->name)->toBe('Workspace')
+ ->and($workspace->monthly_credits_limit)->toBe(2500)
+ ->and($workspace->is_archived)->toBeFalse();
});
test('seeder is idempotent', function () {
$this->seed(PlanSeeder::class);
- expect(Plan::count())->toBe(4);
+ expect(Plan::count())->toBe(1);
});
-test('active scope excludes archived plans', function () {
- $plan = Plan::where('slug', Slug::Starter)->first();
- $plan->update(['is_archived' => true]);
-
- expect(Plan::active()->count())->toBe(3)
- ->and(Plan::count())->toBe(4);
+test('the per-workspace plan is active', function () {
+ expect(Plan::active()->count())->toBe(1)
+ ->and(Plan::active()->first()->slug)->toBe(Slug::Workspace);
});
diff --git a/tests/Feature/PlanTest.php b/tests/Feature/PlanTest.php
index 93f6982d..c58c8de8 100644
--- a/tests/Feature/PlanTest.php
+++ b/tests/Feature/PlanTest.php
@@ -20,28 +20,25 @@
});
test('plan slug is cast to enum', function () {
- $plan = Plan::where('slug', Slug::Starter)->first();
+ $plan = Plan::where('slug', Slug::Workspace)->first();
expect($plan->slug)->toBeInstanceOf(Slug::class)
- ->and($plan->slug)->toBe(Slug::Starter)
- ->and($plan->slug->label())->toBe('Starter');
+ ->and($plan->slug)->toBe(Slug::Workspace)
+ ->and($plan->slug->label())->toBe('Workspace');
});
test('active scope excludes archived plans', function () {
$activeBefore = Plan::active()->count();
- $plan = Plan::where('slug', Slug::Pro)->first();
+ $plan = Plan::where('slug', Slug::Workspace)->first();
$plan->update(['is_archived' => true]);
expect(Plan::active()->count())->toBe($activeBefore - 1);
});
test('integer fields are cast correctly', function () {
- $plan = Plan::where('slug', Slug::Starter)->first();
+ $plan = Plan::where('slug', Slug::Workspace)->first();
- expect($plan->social_account_limit)->toBeInt()
- ->and($plan->member_limit)->toBeInt()
- ->and($plan->workspace_limit)->toBeInt()
- ->and($plan->monthly_credits_limit)->toBeInt()
+ expect($plan->monthly_credits_limit)->toBeInt()
->and($plan->sort)->toBeInt();
});
diff --git a/tests/Feature/Social/BlueskyControllerTest.php b/tests/Feature/Social/BlueskyControllerTest.php
index f2006d66..ebc8d82e 100644
--- a/tests/Feature/Social/BlueskyControllerTest.php
+++ b/tests/Feature/Social/BlueskyControllerTest.php
@@ -79,7 +79,9 @@
]);
});
-test('user can connect multiple bluesky accounts', function () {
+test('user can connect multiple bluesky accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->bluesky()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'did:plc:existing123',
diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php
index d903d959..0dc6949d 100644
--- a/tests/Feature/Social/FacebookControllerTest.php
+++ b/tests/Feature/Social/FacebookControllerTest.php
@@ -83,6 +83,50 @@
]);
});
+test('facebook callback shows network_taken when the network is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Facebook,
+ 'platform_user_id' => 'existing_page',
+ ]);
+
+ session(['social_connect_workspace' => $this->workspace->id]);
+
+ $socialiteUser = Mockery::mock(SocialiteUser::class);
+ $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
+ $socialiteUser->token = 'test-user-token';
+
+ Socialite::shouldReceive('driver')
+ ->with('facebook')
+ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
+
+ Http::fake([
+ 'https://graph.facebook.com/*/me/accounts*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 'page_123',
+ 'name' => 'My Facebook Page',
+ 'username' => 'myfbpage',
+ 'picture' => ['data' => ['url' => null]],
+ 'access_token' => 'page-access-token',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ // The duplicate was blocked by the observer — only the pre-existing account remains.
+ expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(1);
+});
+
test('facebook callback redirects to page selection when multiple pages', function () {
session([
'social_connect_workspace' => $this->workspace->id,
@@ -159,7 +203,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple facebook accounts', function () {
+test('user can connect multiple facebook accounts in self-hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+
SocialAccount::factory()->facebook()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'page_existing',
diff --git a/tests/Feature/Social/InstagramControllerTest.php b/tests/Feature/Social/InstagramControllerTest.php
index f5f92dcf..cdb28fa0 100644
--- a/tests/Feature/Social/InstagramControllerTest.php
+++ b/tests/Feature/Social/InstagramControllerTest.php
@@ -82,7 +82,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple instagram accounts', function () {
+test('user can connect multiple instagram accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
@@ -118,6 +120,43 @@
expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(2);
});
+test('instagram callback shows network_taken when the network is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'existing-ig',
+ ]);
+
+ session(['social_connect_workspace' => $this->workspace->id]);
+
+ $socialiteUser = Mockery::mock(SocialiteUser::class);
+ $socialiteUser->shouldReceive('getId')->andReturn('12345678');
+ $socialiteUser->shouldReceive('getNickname')->andReturn('testuser');
+ $socialiteUser->shouldReceive('getName')->andReturn('Test User');
+ $socialiteUser->shouldReceive('getAvatar')->andReturn(null);
+ $socialiteUser->token = 'test-access-token';
+ $socialiteUser->refreshToken = 'test-refresh-token';
+ $socialiteUser->expiresIn = 5184000;
+ $socialiteUser->user = ['account_type' => 'BUSINESS'];
+
+ Socialite::shouldReceive('driver')
+ ->with('instagram')
+ ->andReturn(Mockery::mock([
+ 'user' => $socialiteUser,
+ ]));
+
+ $response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(1);
+});
+
test('instagram callback handles oauth errors gracefully', function () {
session([
'social_connect_workspace' => $this->workspace->id,
diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php
new file mode 100644
index 00000000..89710a6b
--- /dev/null
+++ b/tests/Feature/Social/InstagramFacebookControllerTest.php
@@ -0,0 +1,94 @@
+user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+ $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
+});
+
+test('instagram-facebook select connects the page in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
+ session([
+ 'social_connect_workspace' => $this->workspace->id,
+ 'instagram_facebook_oauth' => [
+ 'user_token' => 'user-token',
+ 'reconnect_id' => null,
+ 'pages' => [
+ [
+ 'page_id' => 'page-1',
+ 'page_name' => 'My Page',
+ 'page_access_token' => 'page-token',
+ 'ig_id' => 'ig-new',
+ 'ig_username' => 'mybiz',
+ 'ig_name' => 'My Biz',
+ 'ig_picture' => null,
+ ],
+ ],
+ ],
+ ]);
+
+ $response = $this->actingAs($this->user)->post(route('app.social.instagram-facebook.select'), [
+ 'page_id' => 'page-1',
+ ]);
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', true);
+
+ $this->assertDatabaseHas('social_accounts', [
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::InstagramFacebook->value,
+ 'platform_user_id' => 'ig-new',
+ 'username' => 'mybiz',
+ ]);
+});
+
+test('instagram-facebook select shows network_taken when a standalone instagram is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'existing-ig',
+ ]);
+
+ session([
+ 'social_connect_workspace' => $this->workspace->id,
+ 'instagram_facebook_oauth' => [
+ 'user_token' => 'user-token',
+ 'reconnect_id' => null,
+ 'pages' => [
+ [
+ 'page_id' => 'page-1',
+ 'page_name' => 'My Page',
+ 'page_access_token' => 'page-token',
+ 'ig_id' => 'ig-new',
+ 'ig_username' => 'mybiz',
+ 'ig_name' => 'My Biz',
+ 'ig_picture' => null,
+ ],
+ ],
+ ],
+ ]);
+
+ $response = $this->actingAs($this->user)->post(route('app.social.instagram-facebook.select'), [
+ 'page_id' => 'page-1',
+ ]);
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::Instagram->value, Platform::InstagramFacebook->value])->count())->toBe(1);
+});
diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php
index 02ce1a50..953f7ad1 100644
--- a/tests/Feature/Social/LinkedInControllerTest.php
+++ b/tests/Feature/Social/LinkedInControllerTest.php
@@ -135,6 +135,50 @@
]);
});
+test('linkedin callback shows network_taken when the linkedin network is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ // A LinkedIn Page occupies the shared "linkedin" network for this workspace.
+ SocialAccount::factory()->linkedinPage()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform_user_id' => 'existing-linkedin-page',
+ ]);
+
+ session(['social_connect_workspace' => $this->workspace->id]);
+
+ $socialiteUser = Mockery::mock(SocialiteUser::class);
+ $socialiteUser->shouldReceive('getId')->andReturn('new-profile-id');
+ $socialiteUser->shouldReceive('getName')->andReturn('John Doe');
+ $socialiteUser->shouldReceive('getAvatar')->andReturn(null);
+ $socialiteUser->token = 'test-access-token';
+ $socialiteUser->refreshToken = 'test-refresh-token';
+ $socialiteUser->expiresIn = 5184000;
+ $socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social'];
+
+ Socialite::shouldReceive('driver')
+ ->with('linkedin')
+ ->andReturn(Mockery::mock(['user' => $socialiteUser]));
+
+ Http::fake([
+ 'https://api.linkedin.com/v2/me*' => Http::response([
+ 'id' => 'new-profile-id',
+ 'vanityName' => 'johndoe',
+ ], 200),
+ ]);
+
+ $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ $this->assertDatabaseMissing('social_accounts', [
+ 'platform' => Platform::LinkedIn->value,
+ 'platform_user_id' => 'new-profile-id',
+ ]);
+});
+
test('linkedin oauth callback splits comma-separated approvedScopes before saving', function () {
session(['social_connect_workspace' => $this->workspace->id]);
@@ -181,7 +225,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple linkedin accounts', function () {
+test('user can connect multiple linkedin accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'abc123xyz',
diff --git a/tests/Feature/Social/LinkedInPageControllerTest.php b/tests/Feature/Social/LinkedInPageControllerTest.php
index 28911496..ac79d246 100644
--- a/tests/Feature/Social/LinkedInPageControllerTest.php
+++ b/tests/Feature/Social/LinkedInPageControllerTest.php
@@ -214,7 +214,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple linkedin pages', function () {
+test('user can connect multiple linkedin pages in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '123456',
@@ -268,3 +270,42 @@
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
});
+
+test('linkedin page select shows network_taken when a linkedin account is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::LinkedIn,
+ 'platform_user_id' => 'existing-linkedin',
+ ]);
+
+ session([
+ 'linkedin_page_pending' => [
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => 'user123',
+ 'name' => 'John Doe',
+ 'avatar' => null,
+ 'token' => 'test-access-token',
+ 'refresh_token' => 'test-refresh-token',
+ 'expires_in' => 5184000,
+ 'organizations' => [
+ ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
+ ],
+ ],
+ ]);
+
+ $response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
+ 'organization_id' => 123456,
+ 'organization_name' => 'Test Company',
+ 'organization_vanity_name' => 'testcompany',
+ 'organization_logo' => null,
+ ]);
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::LinkedIn->value, Platform::LinkedInPage->value])->count())->toBe(1);
+});
diff --git a/tests/Feature/Social/MastodonControllerTest.php b/tests/Feature/Social/MastodonControllerTest.php
index cab9483d..1882fd20 100644
--- a/tests/Feature/Social/MastodonControllerTest.php
+++ b/tests/Feature/Social/MastodonControllerTest.php
@@ -143,7 +143,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple mastodon accounts', function () {
+test('user can connect multiple mastodon accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->mastodon()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '123456789',
diff --git a/tests/Feature/Social/PinterestControllerTest.php b/tests/Feature/Social/PinterestControllerTest.php
index 4bce71e0..90c32fae 100644
--- a/tests/Feature/Social/PinterestControllerTest.php
+++ b/tests/Feature/Social/PinterestControllerTest.php
@@ -112,7 +112,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple pinterest accounts', function () {
+test('user can connect multiple pinterest accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->pinterest()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'pinterest_user_123',
diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php
index 198b4ddf..f30e90f9 100644
--- a/tests/Feature/Social/TelegramConnectionTest.php
+++ b/tests/Feature/Social/TelegramConnectionTest.php
@@ -5,6 +5,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Events\TelegramChannelConnected;
+use App\Events\TelegramConnectFailed;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@@ -117,10 +118,14 @@ function telegramUpdate(string $code, array $chat = []): array
expect(data_get($account->meta, 'username'))->toBeNull();
});
-it('does not create a new telegram account when the workspace is at its limit', function () {
+it('does not connect a second telegram channel when one is already connected', function () {
config(['trypost.self_hosted' => false]);
+ Event::fake([TelegramConnectFailed::class]);
- SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
+ SocialAccount::factory()->telegram()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform_user_id' => '-1009999999999',
+ ]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
@@ -128,17 +133,43 @@ function telegramUpdate(string $code, array $chat = []): array
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
- expect($this->workspace->socialAccounts()->count())->toBe(5);
+ expect($this->workspace->socialAccounts()->count())->toBe(1);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
)->toBeFalse();
+
+ Event::assertDispatched(
+ TelegramConnectFailed::class,
+ fn (TelegramConnectFailed $event): bool => $event->workspaceId === $this->workspace->id
+ && $event->reason === 'network_taken',
+ );
});
-it('still reconnects an existing telegram channel even at the account limit', function () {
+it('connects a second telegram channel in self-hosted mode', function () {
+ Http::fake();
+ config(['trypost.self_hosted' => true]);
+
+ SocialAccount::factory()->telegram()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform_user_id' => '-1009999999999',
+ ]);
+
+ $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
+
+ $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
+ ->postJson(route('telegram.webhook'), telegramUpdate($code))
+ ->assertNoContent();
+
+ expect($this->workspace->socialAccounts()->count())->toBe(2);
+ expect(
+ SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
+ )->toBeTrue();
+});
+
+it('reconnects an existing telegram channel', function () {
Http::fake();
config(['trypost.self_hosted' => false]);
- SocialAccount::factory()->count(4)->create(['workspace_id' => $this->workspace->id]);
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '-1001234567890',
@@ -150,7 +181,7 @@ function telegramUpdate(string $code, array $chat = []): array
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
- expect($this->workspace->socialAccounts()->count())->toBe(5);
+ expect($this->workspace->socialAccounts()->count())->toBe(1);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->count()
)->toBe(1);
diff --git a/tests/Feature/Social/ThreadsControllerTest.php b/tests/Feature/Social/ThreadsControllerTest.php
index 58f61e31..624097a4 100644
--- a/tests/Feature/Social/ThreadsControllerTest.php
+++ b/tests/Feature/Social/ThreadsControllerTest.php
@@ -105,7 +105,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple threads accounts', function () {
+test('user can connect multiple threads accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->threads()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '123456789',
@@ -169,3 +171,48 @@
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
});
+
+test('threads callback shows network_taken when the network is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ SocialAccount::factory()->threads()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform_user_id' => 'existing-threads',
+ ]);
+
+ $state = bin2hex(random_bytes(16));
+
+ session([
+ 'social_connect_workspace' => $this->workspace->id,
+ 'threads_oauth_state' => $state,
+ ]);
+
+ Http::fake([
+ 'https://graph.threads.net/oauth/access_token' => Http::response([
+ 'access_token' => 'short-lived-token',
+ 'user_id' => '123456789',
+ ], 200),
+ 'https://graph.threads.net/access_token*' => Http::response([
+ 'access_token' => 'long-lived-token',
+ 'expires_in' => 5184000,
+ ], 200),
+ 'https://graph.threads.net/v1.0/123456789*' => Http::response([
+ 'id' => '123456789',
+ 'username' => 'testuser',
+ 'name' => 'Test User',
+ 'threads_profile_picture_url' => null,
+ ], 200),
+ ]);
+
+ $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
+ 'code' => 'test-auth-code',
+ 'state' => $state,
+ ]));
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1);
+});
diff --git a/tests/Feature/Social/TikTokControllerTest.php b/tests/Feature/Social/TikTokControllerTest.php
index 3f17356e..b16cbb4a 100644
--- a/tests/Feature/Social/TikTokControllerTest.php
+++ b/tests/Feature/Social/TikTokControllerTest.php
@@ -86,7 +86,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple tiktok accounts', function () {
+test('user can connect multiple tiktok accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'tiktok123',
diff --git a/tests/Feature/Social/XControllerTest.php b/tests/Feature/Social/XControllerTest.php
index 4dbf8b5f..52661b77 100644
--- a/tests/Feature/Social/XControllerTest.php
+++ b/tests/Feature/Social/XControllerTest.php
@@ -84,7 +84,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple x accounts', function () {
+test('user can connect multiple x accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '123456789',
diff --git a/tests/Feature/Social/YouTubeControllerTest.php b/tests/Feature/Social/YouTubeControllerTest.php
index 1e021420..187b5217 100644
--- a/tests/Feature/Social/YouTubeControllerTest.php
+++ b/tests/Feature/Social/YouTubeControllerTest.php
@@ -182,7 +182,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
-test('user can connect multiple youtube accounts', function () {
+test('user can connect multiple youtube accounts in self-hosted mode', function () {
+ config()->set('trypost.self_hosted', true);
+
SocialAccount::factory()->youtube()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'UC_channel_123',
@@ -301,3 +303,53 @@
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
});
+
+test('youtube callback shows network_taken when the network is already connected', function () {
+ config()->set('trypost.self_hosted', false);
+
+ SocialAccount::factory()->youtube()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform_user_id' => 'UC_existing',
+ ]);
+
+ session([
+ 'social_connect_workspace' => $this->workspace->id,
+ ]);
+
+ $socialiteUser = Mockery::mock(SocialiteUser::class);
+ $socialiteUser->shouldReceive('getId')->andReturn('google_user_123');
+ $socialiteUser->token = 'test-access-token';
+ $socialiteUser->refreshToken = 'test-refresh-token';
+ $socialiteUser->expiresIn = 3600;
+
+ Socialite::shouldReceive('driver')
+ ->with('google')
+ ->andReturn(Mockery::mock([
+ 'user' => $socialiteUser,
+ ]));
+
+ Http::fake([
+ 'https://www.googleapis.com/youtube/v3/channels*' => Http::response([
+ 'items' => [
+ [
+ 'id' => 'UC_channel_123',
+ 'snippet' => [
+ 'title' => 'My YouTube Channel',
+ 'customUrl' => '@mychannel',
+ 'thumbnails' => ['default' => ['url' => null]],
+ ],
+ 'statistics' => ['subscriberCount' => 1000],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
+
+ $response->assertOk();
+ $response->assertViewIs('auth.social-callback');
+ $response->assertViewHas('success', false);
+ $response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
+
+ expect($this->workspace->socialAccounts()->where('platform', Platform::YouTube)->count())->toBe(1);
+});
diff --git a/tests/Feature/SocialAccount/NetworkUniquenessTest.php b/tests/Feature/SocialAccount/NetworkUniquenessTest.php
new file mode 100644
index 00000000..f25d109e
--- /dev/null
+++ b/tests/Feature/SocialAccount/NetworkUniquenessTest.php
@@ -0,0 +1,125 @@
+set('trypost.self_hosted', false);
+ $this->workspace = Workspace::factory()->create();
+});
+
+test('blocks a second account of the same network', function () {
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-a',
+ ]);
+
+ expect(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-b',
+ ]))->toThrow(NetworkAlreadyConnectedException::class);
+});
+
+test('collapses platform variants into one network', function () {
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::LinkedIn,
+ 'platform_user_id' => 'li-profile',
+ ]);
+
+ expect(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::LinkedInPage,
+ 'platform_user_id' => 'li-page',
+ ]))->toThrow(NetworkAlreadyConnectedException::class);
+});
+
+test('allows different networks in the same workspace', function () {
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-a',
+ ]);
+
+ $x = SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::X,
+ 'platform_user_id' => 'x-a',
+ ]);
+
+ expect($x->exists)->toBeTrue();
+});
+
+test('allows the same network in different workspaces', function () {
+ $other = Workspace::factory()->create();
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-a',
+ ]);
+
+ $second = SocialAccount::factory()->create([
+ 'workspace_id' => $other->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-a',
+ ]);
+
+ expect($second->exists)->toBeTrue();
+});
+
+test('reconnecting the same account via updateOrCreate is allowed', function () {
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-a',
+ 'username' => 'old',
+ ]);
+
+ $this->workspace->socialAccounts()->updateOrCreate(
+ ['platform' => Platform::Instagram->value, 'platform_user_id' => 'ig-a'],
+ ['username' => 'new', 'status' => Status::Connected],
+ );
+
+ expect($this->workspace->socialAccounts()->count())->toBe(1)
+ ->and($this->workspace->socialAccounts()->first()->username)->toBe('new');
+});
+
+test('self-hosted mode bypasses the one-per-network rule', function () {
+ config()->set('trypost.self_hosted', true);
+
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-a',
+ ]);
+
+ $second = SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'ig-b',
+ ]);
+
+ expect($second->exists)->toBeTrue();
+});
+
+test('blocks a same-id account connected via a different network variant', function () {
+ SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ 'platform_user_id' => 'shared-ig-id',
+ ]);
+
+ expect(fn () => SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::InstagramFacebook,
+ 'platform_user_id' => 'shared-ig-id',
+ ]))->toThrow(NetworkAlreadyConnectedException::class);
+});
diff --git a/tests/Feature/SocialControllerTest.php b/tests/Feature/SocialControllerTest.php
index b808b794..a0ed0a12 100644
--- a/tests/Feature/SocialControllerTest.php
+++ b/tests/Feature/SocialControllerTest.php
@@ -38,9 +38,23 @@
->component('accounts/Index', false)
->has('workspace')
->has('platforms')
+ ->has('platforms.0.network')
+ ->has('connectedAccounts', 1)
);
});
+test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
+
+ $response = $this->actingAs($this->user)->delete(route('app.accounts.disconnect', $account));
+
+ $response->assertRedirect();
+ $response->assertSessionMissing('errors');
+ expect(SocialAccount::find($account->id))->toBeNull();
+});
+
test('accounts index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
diff --git a/tests/Feature/SocialLoginControllerTest.php b/tests/Feature/SocialLoginControllerTest.php
index b0df0284..382710f2 100644
--- a/tests/Feature/SocialLoginControllerTest.php
+++ b/tests/Feature/SocialLoginControllerTest.php
@@ -66,8 +66,36 @@
expect($user->name)->toBe('New User');
expect($user->email_verified_at)->not->toBeNull();
expect($user->account_id)->not->toBeNull();
- expect($user->workspaces()->count())->toBe(0);
- expect($user->current_workspace_id)->toBeNull();
+ expect($user->workspaces()->count())->toBe(1);
+ expect($user->workspaces()->first()->name)->toBe("New User's Workspace");
+ expect($user->current_workspace_id)->toBe($user->workspaces()->first()->id);
+ $this->assertAuthenticatedAs($user);
+});
+
+test('github callback creates new user with a default workspace', function () {
+ $socialiteUser = new SocialiteUser;
+ $socialiteUser->map([
+ 'id' => '987',
+ 'name' => 'New Dev',
+ 'email' => 'newdev@example.com',
+ ]);
+
+ Socialite::shouldReceive('driver')
+ ->with('github')
+ ->andReturn($driver = Mockery::mock());
+ $driver->shouldReceive('user')->andReturn($socialiteUser);
+
+ $response = $this->get(route('auth.github.callback'));
+
+ $response->assertRedirect(route('register.success'));
+
+ $user = User::where('email', 'newdev@example.com')->first();
+ expect($user)->not->toBeNull();
+ expect($user->github_id)->toBe('987');
+ expect($user->name)->toBe('New Dev');
+ expect($user->workspaces()->count())->toBe(1);
+ expect($user->workspaces()->first()->name)->toBe("New Dev's Workspace");
+ expect($user->current_workspace_id)->toBe($user->workspaces()->first()->id);
$this->assertAuthenticatedAs($user);
});
diff --git a/tests/Feature/WorkspaceBillingTest.php b/tests/Feature/WorkspaceBillingTest.php
index 7eaf9142..5ca5ab89 100644
--- a/tests/Feature/WorkspaceBillingTest.php
+++ b/tests/Feature/WorkspaceBillingTest.php
@@ -38,7 +38,7 @@
});
test('account belongs to plan', function () {
- $plan = Plan::where('slug', PlanSlug::Starter)->first();
+ $plan = Plan::where('slug', PlanSlug::Workspace)->first();
$this->account->update(['plan_id' => $plan->id]);
expect($this->account->fresh()->plan->id)->toBe($plan->id);
@@ -57,7 +57,7 @@
$response = $this->actingAs($this->user)->get(route('app.calendar'));
- $response->assertRedirect(route('app.subscribe'));
+ $response->assertRedirect(route('app.onboarding'));
});
test('billing page is accessible by account owner', function () {
@@ -96,18 +96,12 @@
$response->assertForbidden();
});
-test('subscribe page shows plans', function () {
+test('subscribe redirects to onboarding', function () {
config(['trypost.self_hosted' => false]);
- $activePlanCount = Plan::active()->count();
-
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
- $response->assertOk();
- $response->assertInertia(fn ($page) => $page
- ->component('billing/Subscribe', false)
- ->has('plans', $activePlanCount)
- );
+ $response->assertRedirect(route('app.onboarding'));
});
test('stripe email returns account owner email', function () {
@@ -118,32 +112,18 @@
expect($this->account->stripeName())->toBe($this->account->name);
});
-test('create workspace is blocked when account hits plan limit in saas mode', function () {
+test('creating an additional workspace redirects to billing without an active subscription', function () {
config(['trypost.self_hosted' => false]);
- $starter = Plan::where('slug', PlanSlug::Starter)->first();
- $this->account->update(['plan_id' => $starter->id]);
- $this->account->subscriptions()->create([
- 'type' => Account::SUBSCRIPTION_NAME,
- 'stripe_id' => 'sub_test_'.fake()->uuid(),
- 'stripe_status' => 'active',
- 'stripe_price' => 'price_123',
- ]);
+ // The account already owns one workspace (from beforeEach) and has no subscription.
+ $response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
- // Starter plan: workspace_limit = 1; the user already owns 1 workspace (from beforeEach).
- $response = $this->actingAs($this->user)
- ->from(route('app.calendar'))
- ->get(route('app.workspaces.create'));
-
- $response->assertRedirect(route('app.calendar'))
- ->assertSessionHas('flash.error', __('workspaces.limit_reached'));
+ $response->assertRedirect(route('app.billing.index'));
});
-test('create workspace is allowed when below plan limit in saas mode', function () {
+test('creating an additional workspace is allowed with an active subscription', function () {
config(['trypost.self_hosted' => false]);
- $pro = Plan::where('slug', PlanSlug::Pro)->first();
- $this->account->update(['plan_id' => $pro->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
@@ -151,40 +131,18 @@
'stripe_price' => 'price_123',
]);
- // Pro plan: workspace_limit = 15; account has 1 workspace.
$response = $this->actingAs($this->user->fresh())->get(route('app.workspaces.create'));
$response->assertOk();
});
-test('store workspace is blocked when account hits plan limit in saas mode', function () {
- config(['trypost.self_hosted' => false]);
+test('store creates an additional workspace with no count limit', function () {
+ config(['trypost.self_hosted' => true]);
- $starter = Plan::where('slug', PlanSlug::Starter)->first();
- $this->account->update(['plan_id' => $starter->id]);
- $this->account->subscriptions()->create([
- 'type' => Account::SUBSCRIPTION_NAME,
- 'stripe_id' => 'sub_test_'.fake()->uuid(),
- 'stripe_status' => 'active',
- 'stripe_price' => 'price_123',
- ]);
-
- // Starter plan: workspace_limit = 1; the user already owns 1 workspace.
$response = $this->actingAs($this->user)->post(route('app.workspaces.store'), [
'name' => 'Second workspace',
]);
- $response->assertForbidden();
-});
-
-test('workspace limit is bypassed in self-hosted mode', function () {
- config(['trypost.self_hosted' => true]);
-
- $starter = Plan::where('slug', PlanSlug::Starter)->first();
- $this->account->update(['plan_id' => $starter->id]);
-
- // Even on Starter (limit 1) with an existing workspace, self-hosted lets through.
- $response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
-
- $response->assertOk();
+ $response->assertRedirect(route('app.accounts'));
+ expect($this->account->workspaces()->count())->toBe(2);
});
diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php
index 6b04cd7b..b568d932 100644
--- a/tests/Feature/WorkspaceControllerTest.php
+++ b/tests/Feature/WorkspaceControllerTest.php
@@ -2,8 +2,10 @@
declare(strict_types=1);
+use App\Ai\Agents\BrandAnalyzer;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
+use App\Models\AiUsageLog;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Brand\LogoAttacher;
@@ -87,7 +89,7 @@
'name' => 'New Workspace',
]);
- $response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
+ $response->assertRedirect(route('app.accounts'));
$this->assertDatabaseHas('workspaces', [
'name' => 'New Workspace',
@@ -102,7 +104,7 @@
'name' => 'Second Workspace',
]);
- $response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
+ $response->assertRedirect(route('app.accounts'));
$this->assertDatabaseHas('workspaces', [
'name' => 'Second Workspace',
@@ -405,6 +407,10 @@
});
test('destroy workspace deletes the workspace', function () {
+ Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
$workspaceId = $this->workspace->id;
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
@@ -414,12 +420,43 @@
});
test('destroy workspace clears current workspace if deleting current', function () {
+ Workspace::factory()->create([
+ 'account_id' => $this->user->account_id,
+ 'user_id' => $this->user->id,
+ ]);
+
$this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
$this->user->refresh();
expect($this->user->current_workspace_id)->toBeNull();
});
+test('destroy workspace is blocked when it is the only workspace', function () {
+ config(['trypost.self_hosted' => false]);
+ $this->user->account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_test_'.fake()->uuid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_123',
+ ]);
+ $workspaceId = $this->workspace->id;
+
+ $response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
+
+ $response->assertSessionHas('flash.error', __('workspaces.cannot_delete_last'));
+ expect(Workspace::find($workspaceId))->not->toBeNull();
+});
+
+test('destroy workspace allows deleting the only workspace in self-hosted mode', function () {
+ config(['trypost.self_hosted' => true]);
+ $workspaceId = $this->workspace->id;
+
+ $response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
+
+ $response->assertRedirect(route('app.workspaces.index'));
+ expect(Workspace::find($workspaceId))->toBeNull();
+});
+
test('destroy workspace returns 403 for non-owner', function () {
$otherUser = User::factory()->create([]);
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
@@ -467,6 +504,57 @@
expect(Workspace::count())->toBe($initialWorkspaceCount);
});
+test('autofillBrand is always allowed even without a subscription', function () {
+ config(['trypost.self_hosted' => false]);
+
+ $account = Account::factory()->create(['trial_ends_at' => null]);
+ $user = User::factory()->create(['account_id' => $account->id]);
+ $account->update(['owner_id' => $user->id]);
+
+ expect($account->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse();
+
+ Http::fake([
+ 'example.com/*' => Http::response(
+ '
Acme',
+ 200,
+ ['Content-Type' => 'text/html'],
+ ),
+ 'example.com' => Http::response(
+ '
Acme',
+ 200,
+ ['Content-Type' => 'text/html'],
+ ),
+ ]);
+
+ $this->actingAs($user)
+ ->postJson(route('app.workspaces.autofill'), ['url' => 'https://example.com'])
+ ->assertOk();
+});
+
+test('autofillBrand never records AI usage even when the LLM runs', function () {
+ config(['trypost.self_hosted' => false]);
+ config()->set('services.gemini.api_key', 'fake-key');
+ config()->set('ai.default', 'gemini');
+
+ Http::fake([
+ 'example.com' => Http::response(
+ '
AcmeAcme ships rockets fast.
',
+ 200,
+ ['Content-Type' => 'text/html'],
+ ),
+ ]);
+
+ BrandAnalyzer::fake([
+ ['description' => 'Acme ships rockets fast.', 'language' => 'en'],
+ ]);
+
+ $this->actingAs($this->user)
+ ->postJson(route('app.workspaces.autofill'), ['url' => 'https://example.com'])
+ ->assertOk();
+
+ expect(AiUsageLog::count())->toBe(0);
+});
+
test('autofillBrand validates url is required', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
@@ -478,7 +566,7 @@
});
// Brand-aware store tests
-test('store persists brand fields and redirects to /accounts with openDialog flag', function () {
+test('store persists brand fields and redirects to /accounts', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $user->id]);
@@ -498,7 +586,7 @@
'content_language' => 'en',
]);
- $response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
+ $response->assertRedirect(route('app.accounts'));
$workspace = Workspace::where('name', 'Acme Inc')->sole();
expect($workspace->name)->toBe('Acme Inc');
@@ -506,7 +594,7 @@
expect($workspace->brand_description)->toBe('We sell rockets.');
});
-test('store redirects additional workspace to /accounts with openDialog flag', function () {
+test('store redirects additional workspace to /accounts', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $user->id]);
@@ -526,7 +614,7 @@
'name' => 'Second Workspace',
]);
- $response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
+ $response->assertRedirect(route('app.accounts'));
expect(Workspace::where('account_id', $account->id)->count())->toBe(2);
});
diff --git a/tests/Feature/WorkspaceInviteControllerTest.php b/tests/Feature/WorkspaceInviteControllerTest.php
index 525c6cba..bff21193 100644
--- a/tests/Feature/WorkspaceInviteControllerTest.php
+++ b/tests/Feature/WorkspaceInviteControllerTest.php
@@ -80,6 +80,27 @@
Mail::assertQueued(WorkspaceInviteMail::class);
});
+test('store invite requires a role', function () {
+ $response = $this->actingAs($this->user)->post(route('app.invites.store'), [
+ 'email' => 'newmember@example.com',
+ ]);
+
+ $response->assertSessionHasErrors('role');
+ $this->assertDatabaseMissing('invites', ['email' => 'newmember@example.com']);
+});
+
+test('store invite persists the chosen role', function () {
+ $this->actingAs($this->user)->post(route('app.invites.store'), [
+ 'email' => 'viewer@example.com',
+ 'role' => WorkspaceRole::Viewer->value,
+ ]);
+
+ $this->assertDatabaseHas('invites', [
+ 'email' => 'viewer@example.com',
+ 'role' => WorkspaceRole::Viewer->value,
+ ]);
+});
+
test('store invite fails if invite already exists', function () {
Invite::factory()->create([
'account_id' => $this->account->id,
@@ -206,6 +227,48 @@
expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Admin->value);
});
+test('update role changes member to viewer', function () {
+ $member = User::factory()->create([
+ 'account_id' => $this->account->id,
+ ]);
+ $this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
+
+ $response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
+ 'role' => WorkspaceRole::Viewer->value,
+ ]);
+
+ $response->assertRedirect();
+ expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Viewer->value);
+});
+
+test('an admin cannot change their own role', function () {
+ $admin = User::factory()->create([
+ 'account_id' => $this->account->id,
+ ]);
+ $this->workspace->members()->attach($admin->id, ['role' => WorkspaceRole::Admin->value]);
+ $admin->update(['current_workspace_id' => $this->workspace->id]);
+
+ $response = $this->actingAs($admin)->put(route('app.members.update-role', $admin), [
+ 'role' => WorkspaceRole::Member->value,
+ ]);
+
+ $response->assertSessionHasErrors('role');
+ expect($this->workspace->members()->where('user_id', $admin->id)->first()->pivot->role)->toBe(WorkspaceRole::Admin->value);
+});
+
+test('an admin cannot remove themselves', function () {
+ $admin = User::factory()->create([
+ 'account_id' => $this->account->id,
+ ]);
+ $this->workspace->members()->attach($admin->id, ['role' => WorkspaceRole::Admin->value]);
+ $admin->update(['current_workspace_id' => $this->workspace->id]);
+
+ $response = $this->actingAs($admin)->delete(route('app.members.remove', $admin));
+
+ $response->assertSessionHasErrors('member');
+ expect($this->workspace->members()->where('user_id', $admin->id)->exists())->toBeTrue();
+});
+
test('update role changes admin to member', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
diff --git a/tests/Pest.php b/tests/Pest.php
index 523a1605..251d4171 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -4,6 +4,8 @@
use App\Enums\UserWorkspace\Role;
use App\Models\AccessToken;
+use App\Models\Account;
+use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -108,3 +110,48 @@ function feedFixture(string $name): string
{
return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml"));
}
+
+/**
+ * Create an account on the Workspace plan with an active subscription on the
+ * given Stripe price, plus N workspaces. Used by the billing-cycle tests.
+ *
+ * @param array
$subscriptionAttributes
+ */
+function billingAccount(string $price, array $subscriptionAttributes = [], int $workspaces = 1): Account
+{
+ $plan = Plan::query()->firstOrFail();
+ $plan->update([
+ 'stripe_monthly_price_id' => 'price_month',
+ 'stripe_yearly_price_id' => 'price_year',
+ ]);
+
+ $account = Account::factory()->create([
+ 'plan_id' => $plan->id,
+ 'trial_ends_at' => null,
+ ]);
+
+ $account->subscriptions()->create(array_merge([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => $price,
+ 'quantity' => $workspaces,
+ ], $subscriptionAttributes));
+
+ Workspace::factory()->count($workspaces)->create(['account_id' => $account->id]);
+
+ return $account->refresh();
+}
+
+/**
+ * Attach an active default subscription to the given account.
+ */
+function subscribeAccount(Account $account): void
+{
+ $account->subscriptions()->create([
+ 'type' => Account::SUBSCRIPTION_NAME,
+ 'stripe_id' => 'sub_'.fake()->uuid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_123',
+ ]);
+}
diff --git a/tests/Unit/Enums/PersonaTest.php b/tests/Unit/Enums/PersonaTest.php
new file mode 100644
index 00000000..e73e907a
--- /dev/null
+++ b/tests/Unit/Enums/PersonaTest.php
@@ -0,0 +1,18 @@
+ $persona->value, Persona::cases()))
+ ->toBe(['creator', 'freelancer', 'startup', 'agency', 'small_business', 'other']);
+});
+
+test('every persona has an onboarding label in every locale', function (string $locale) {
+ foreach (Persona::cases() as $persona) {
+ $key = "onboarding.personas.{$persona->value}";
+
+ expect(__($key, [], $locale))->not->toBe($key);
+ }
+})->with(['en', 'es', 'pt-BR']);
diff --git a/tests/Unit/Enums/PlatformNetworkTest.php b/tests/Unit/Enums/PlatformNetworkTest.php
new file mode 100644
index 00000000..982a9c50
--- /dev/null
+++ b/tests/Unit/Enums/PlatformNetworkTest.php
@@ -0,0 +1,29 @@
+network())->toBe('linkedin')
+ ->and(Platform::LinkedInPage->network())->toBe('linkedin');
+});
+
+test('network groups the two instagram variants together', function () {
+ expect(Platform::Instagram->network())->toBe('instagram')
+ ->and(Platform::InstagramFacebook->network())->toBe('instagram');
+});
+
+test('network returns the platform value for single-variant networks', function () {
+ expect(Platform::X->network())->toBe('x')
+ ->and(Platform::Facebook->network())->toBe('facebook')
+ ->and(Platform::TikTok->network())->toBe('tiktok')
+ ->and(Platform::YouTube->network())->toBe('youtube')
+ ->and(Platform::Bluesky->network())->toBe('bluesky');
+});
+
+test('every platform resolves to a non-empty network', function () {
+ foreach (Platform::cases() as $platform) {
+ expect($platform->network())->toBeString()->not->toBe('');
+ }
+});
diff --git a/tests/Unit/Features/BrandLimitTest.php b/tests/Unit/Features/BrandLimitTest.php
deleted file mode 100644
index eb5f9f80..00000000
--- a/tests/Unit/Features/BrandLimitTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
- 5]);
- $account = new Account;
- $account->setRelation('plan', $plan);
-
- expect((new WorkspaceLimit)->resolve($account))->toBe(5);
-});
-
-test('falls back to 1 when no plan', function () {
- $account = new Account;
- $account->setRelation('plan', null);
-
- expect((new WorkspaceLimit)->resolve($account))->toBe(1);
-});
diff --git a/tests/Unit/Features/MemberLimitTest.php b/tests/Unit/Features/MemberLimitTest.php
deleted file mode 100644
index 1277d170..00000000
--- a/tests/Unit/Features/MemberLimitTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
- 10]);
- $account = new Account;
- $account->setRelation('plan', $plan);
-
- expect((new MemberLimit)->resolve($account))->toBe(10);
-});
-
-test('falls back to 1 when no plan', function () {
- $account = new Account;
- $account->setRelation('plan', null);
-
- expect((new MemberLimit)->resolve($account))->toBe(1);
-});
diff --git a/tests/Unit/Features/MonthlyCreditsLimitTest.php b/tests/Unit/Features/MonthlyCreditsLimitTest.php
deleted file mode 100644
index b791e562..00000000
--- a/tests/Unit/Features/MonthlyCreditsLimitTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
- 5000]);
- $account = new Account;
- $account->setRelation('plan', $plan);
-
- expect((new MonthlyCreditsLimit)->resolve($account))->toBe(5000);
-});
-
-test('falls back to 1000 when no plan', function () {
- $account = new Account;
- $account->setRelation('plan', null);
-
- expect((new MonthlyCreditsLimit)->resolve($account))->toBe(1000);
-});
diff --git a/tests/Unit/Features/SocialAccountLimitTest.php b/tests/Unit/Features/SocialAccountLimitTest.php
deleted file mode 100644
index fc469846..00000000
--- a/tests/Unit/Features/SocialAccountLimitTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
- 30]);
- $account = new Account;
- $account->setRelation('plan', $plan);
-
- expect((new SocialAccountLimit)->resolve($account))->toBe(30);
-});
-
-test('falls back to 5 when no plan', function () {
- $account = new Account;
- $account->setRelation('plan', null);
-
- expect((new SocialAccountLimit)->resolve($account))->toBe(5);
-});
diff --git a/tests/Unit/Policies/AccountPolicyTest.php b/tests/Unit/Policies/AccountPolicyTest.php
index 2e5f87c5..6ec31e6e 100644
--- a/tests/Unit/Policies/AccountPolicyTest.php
+++ b/tests/Unit/Policies/AccountPolicyTest.php
@@ -2,124 +2,73 @@
declare(strict_types=1);
-use App\Enums\UserWorkspace\Role;
+use App\Enums\Plan\Slug;
use App\Models\Account;
use App\Models\AiUsageLog;
-use App\Models\Invite;
use App\Models\Plan;
-use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Policies\AccountPolicy;
beforeEach(function () {
$this->policy = new AccountPolicy;
- $this->account = Account::factory()->create();
+ $this->account = Account::factory()->create([
+ 'plan_id' => Plan::where('slug', Slug::Workspace)->value('id'),
+ ]);
$this->owner = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->owner->id]);
});
-test('swapPlan allows account owner with usage fitting the target plan', function () {
- // Plus: workspace_limit=5, social_account_limit=10, member_limit=5
- $plan = Plan::where('slug', 'plus')->first();
-
- $response = $this->policy->swapPlan($this->owner, $this->account, $plan);
+test('swapPlan allows the account owner', function () {
+ $response = $this->policy->swapPlan($this->owner, $this->account);
expect($response->allowed())->toBeTrue();
});
-test('swapPlan denies non-owner', function () {
+test('swapPlan denies a non-owner', function () {
$member = User::factory()->create(['account_id' => $this->account->id]);
- $plan = Plan::where('slug', 'plus')->first();
- $response = $this->policy->swapPlan($member, $this->account, $plan);
+ $response = $this->policy->swapPlan($member, $this->account);
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.cannot_manage'));
});
-test('swapPlan denies when workspace count exceeds target plan limit', function () {
- Workspace::factory()->count(3)->create([
- 'account_id' => $this->account->id,
- 'user_id' => $this->owner->id,
- ]);
-
- // Starter: workspace_limit=1
- $plan = Plan::where('slug', 'starter')->first();
-
- $response = $this->policy->swapPlan($this->owner, $this->account, $plan);
-
- expect($response->denied())->toBeTrue();
- expect($response->message())->toBe(__('billing.flash.cannot_downgrade.workspaces', [
- 'plan' => $plan->name,
- 'count' => '3',
- 'limit' => '1',
- ]));
-});
-
-test('swapPlan denies when social account count exceeds target plan limit', function () {
- $workspace = Workspace::factory()->create([
- 'account_id' => $this->account->id,
- 'user_id' => $this->owner->id,
- ]);
- SocialAccount::factory()->count(6)->create(['workspace_id' => $workspace->id]);
-
- // Starter: social_account_limit=5
- $plan = Plan::where('slug', 'starter')->first();
-
- $response = $this->policy->swapPlan($this->owner, $this->account, $plan);
-
- expect($response->denied())->toBeTrue();
- expect($response->message())->toBe(__('billing.flash.cannot_downgrade.social_accounts', [
- 'plan' => $plan->name,
- 'count' => '6',
- 'limit' => '5',
- ]));
-});
-
-test('swapPlan denies when members + pending invites exceed target plan limit', function () {
- User::factory()->count(2)->create(['account_id' => $this->account->id]);
- Invite::factory()->count(2)->create([
- 'account_id' => $this->account->id,
- 'invited_by' => $this->owner->id,
- 'role' => Role::Member,
- ]);
-
- // Starter: member_limit=1
- $plan = Plan::where('slug', 'starter')->first();
-
- $response = $this->policy->swapPlan($this->owner, $this->account, $plan);
-
- expect($response->denied())->toBeTrue();
- expect($response->message())->toBe(__('billing.flash.cannot_downgrade.members', [
- 'plan' => $plan->name,
- 'count' => '5',
- 'limit' => '1',
- ]));
-});
-
-test('useAi allows when account has credits remaining', function () {
+test('useAi allows when subscribed and credits remain', function () {
config()->set('trypost.self_hosted', false);
-
- $plan = Plan::where('slug', 'starter')->first();
- $this->account->update(['plan_id' => $plan->id]);
+ Workspace::factory()->create([
+ 'account_id' => $this->account->id,
+ 'user_id' => $this->owner->id,
+ ]);
+ subscribeAccount($this->account);
$response = $this->policy->useAi($this->owner, $this->account->fresh());
expect($response->allowed())->toBeTrue();
});
-test('useAi denies when monthly credits are exhausted', function () {
+test('useAi denies when there is no active subscription', function () {
config()->set('trypost.self_hosted', false);
-
- $plan = Plan::where('slug', 'starter')->first();
- $this->account->update(['plan_id' => $plan->id]);
- $workspace = Workspace::factory()->create([
+ Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
- AiUsageLog::factory()->text(credits: $plan->monthly_credits_limit)->create([
+ $response = $this->policy->useAi($this->owner, $this->account->fresh());
+
+ expect($response->denied())->toBeTrue();
+ expect($response->message())->toBe(__('billing.flash.subscription_required'));
+});
+
+test('useAi denies when monthly credits are exhausted', function () {
+ config()->set('trypost.self_hosted', false);
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $this->account->id,
+ 'user_id' => $this->owner->id,
+ ]);
+ subscribeAccount($this->account);
+
+ AiUsageLog::factory()->text(credits: 2500)->create([
'account_id' => $this->account->id,
'workspace_id' => $workspace->id,
]);
@@ -128,7 +77,7 @@
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.credits_exhausted', [
- 'limit' => (string) $plan->monthly_credits_limit,
+ 'limit' => '2500',
]));
});
@@ -139,17 +88,3 @@
expect($response->allowed())->toBeTrue();
});
-
-test('swapPlan allows when usage equals target plan limit (boundary)', function () {
- Workspace::factory()->create([
- 'account_id' => $this->account->id,
- 'user_id' => $this->owner->id,
- ]);
-
- // Starter: workspace_limit=1, owner has exactly 1 workspace
- $plan = Plan::where('slug', 'starter')->first();
-
- $response = $this->policy->swapPlan($this->owner, $this->account, $plan);
-
- expect($response->allowed())->toBeTrue();
-});
diff --git a/tests/Unit/Policies/WorkspacePolicyTest.php b/tests/Unit/Policies/WorkspacePolicyTest.php
index 63f2c22a..7bc3bd45 100644
--- a/tests/Unit/Policies/WorkspacePolicyTest.php
+++ b/tests/Unit/Policies/WorkspacePolicyTest.php
@@ -256,6 +256,27 @@
expect($this->policy->createPost($otherUser, $workspace))->toBeFalse();
});
+test('a viewer can view but cannot create posts or manage the team', function () {
+ $account = Account::factory()->create();
+ $owner = User::factory()->create([
+ 'account_id' => $account->id,
+ ]);
+ $account->update(['owner_id' => $owner->id]);
+ $viewer = User::factory()->create([
+ 'account_id' => $account->id,
+ ]);
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $account->id,
+ 'user_id' => $owner->id,
+ ]);
+ $workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]);
+
+ expect($this->policy->view($viewer, $workspace))->toBeTrue();
+ expect($this->policy->createPost($viewer, $workspace))->toBeFalse();
+ expect($this->policy->manageTeam($viewer, $workspace))->toBeFalse();
+ expect($this->policy->inviteMember($viewer, $workspace))->toBeFalse();
+});
+
test('only account owner can manage billing', function () {
$account = Account::factory()->create();
$owner = User::factory()->create([
diff --git a/tests/Unit/Rules/TimezoneRuleTest.php b/tests/Unit/Rules/TimezoneRuleTest.php
deleted file mode 100644
index 98c77a07..00000000
--- a/tests/Unit/Rules/TimezoneRuleTest.php
+++ /dev/null
@@ -1,48 +0,0 @@
- 'America/Sao_Paulo'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeTrue();
-});
-
-test('accepts UTC', function () {
- $validator = Validator::make(['tz' => 'UTC'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeTrue();
-});
-
-test('accepts deprecated timezone Asia/Calcutta', function () {
- $validator = Validator::make(['tz' => 'Asia/Calcutta'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeTrue();
-});
-
-test('accepts deprecated timezone US/Eastern', function () {
- $validator = Validator::make(['tz' => 'US/Eastern'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeTrue();
-});
-
-test('rejects invalid timezone', function () {
- $validator = Validator::make(['tz' => 'Invalid/Timezone'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeFalse();
-});
-
-test('rejects random string', function () {
- $validator = Validator::make(['tz' => 'blabla'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeFalse();
-});
-
-test('accepts Europe/London', function () {
- $validator = Validator::make(['tz' => 'Europe/London'], ['tz' => new Timezone]);
-
- expect($validator->passes())->toBeTrue();
-});