2026-01-18 18:56:51 +00:00
< ? php
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
declare ( strict_types = 1 );
2026-01-18 18:56:51 +00:00
use App\Enums\SocialAccount\Platform ;
use App\Enums\SocialAccount\Status ;
2026-03-31 03:40:18 +00:00
use App\Enums\UserWorkspace\Role ;
2026-01-18 18:56:51 +00:00
use App\Models\SocialAccount ;
use App\Models\User ;
use App\Models\Workspace ;
use Illuminate\Support\Facades\Http ;
2026-06-25 16:54:16 +00:00
use Inertia\Testing\AssertableInertia ;
2026-01-18 18:56:51 +00:00
use Laravel\Socialite\Facades\Socialite ;
use Laravel\Socialite\Two\User as SocialiteUser ;
beforeEach ( function () {
$this -> user = User :: factory () -> create ();
$this -> workspace = Workspace :: factory () -> create ([ 'user_id' => $this -> user -> id ]);
$this -> user -> update ([ 'current_workspace_id' => $this -> workspace -> id ]);
2026-04-15 01:22:04 +00:00
$this -> workspace -> members () -> attach ( $this -> user -> id , [ 'role' => Role :: Member -> value ]);
2026-01-18 18:56:51 +00:00
});
test ( 'facebook connect redirects to oauth provider' , function () {
2026-04-14 17:44:15 +00:00
$driverMock = Mockery :: mock ();
$driverMock -> shouldReceive ( 'usingGraphVersion' ) -> andReturnSelf ();
$driverMock -> shouldReceive ( 'setScopes' ) -> andReturnSelf ();
$driverMock -> shouldReceive ( 'redirect' ) -> andReturn ( Mockery :: mock ([
'getTargetUrl' => 'https://www.facebook.com/v25.0/dialog/oauth?test=1' ,
]));
2026-01-18 18:56:51 +00:00
Socialite :: shouldReceive ( 'driver' )
-> with ( 'facebook' )
2026-04-14 17:44:15 +00:00
-> andReturn ( $driverMock );
2026-01-18 18:56:51 +00:00
$response = $this -> actingAs ( $this -> user )
-> withHeader ( 'X-Inertia' , 'true' )
2026-03-29 22:24:28 +00:00
-> get ( route ( 'app.social.facebook.connect' ));
2026-01-18 18:56:51 +00:00
$response -> assertStatus ( 409 ); // Inertia::location returns 409 with X-Inertia header
expect ( session ( 'social_connect_workspace' )) -> toBe ( $this -> workspace -> id );
});
test ( 'facebook oauth callback creates account with single page' , function () {
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' )
2026-04-02 20:57:06 +00:00
-> andReturn ( Mockery :: mock () -> shouldReceive ( 'usingGraphVersion' ) -> andReturnSelf () -> shouldReceive ( 'user' ) -> andReturn ( $socialiteUser ) -> getMock ());
2026-01-18 18:56:51 +00:00
Http :: fake ([
2026-04-02 20:57:06 +00:00
'https://graph.facebook.com/*/me/accounts*' => Http :: response ([
2026-01-18 18:56:51 +00:00
'data' => [
[
'id' => 'page_123' ,
'name' => 'My Facebook Page' ,
'username' => 'myfbpage' ,
'picture' => [ 'data' => [ 'url' => null ]],
'access_token' => 'page-access-token' ,
],
],
], 200 ),
]);
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> get ( route ( 'app.social.facebook.callback' ));
2026-01-18 18:56:51 +00:00
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> component ( 'accounts/PopupCallback' ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , true ));
2026-01-18 18:56:51 +00:00
$this -> assertDatabaseHas ( 'social_accounts' , [
'workspace_id' => $this -> workspace -> id ,
'platform' => Platform :: Facebook -> value ,
'platform_user_id' => 'page_123' ,
'username' => 'myfbpage' ,
'display_name' => 'My Facebook Page' ,
'status' => Status :: Connected -> value ,
]);
});
2026-06-22 11:01:08 +00:00
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 ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> component ( 'accounts/PopupCallback' ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , false ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'message' , __ ( 'accounts.popup_callback.network_taken' )));
2026-06-22 11:01:08 +00:00
// The duplicate was blocked by the observer — only the pre-existing account remains.
expect ( $this -> workspace -> socialAccounts () -> where ( 'platform' , Platform :: Facebook ) -> count ()) -> toBe ( 1 );
});
2026-01-18 18:56:51 +00:00
test ( 'facebook callback redirects to page selection when multiple pages' , function () {
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' )
2026-04-02 20:57:06 +00:00
-> andReturn ( Mockery :: mock () -> shouldReceive ( 'usingGraphVersion' ) -> andReturnSelf () -> shouldReceive ( 'user' ) -> andReturn ( $socialiteUser ) -> getMock ());
2026-01-18 18:56:51 +00:00
Http :: fake ([
2026-04-02 20:57:06 +00:00
'https://graph.facebook.com/*/me/accounts*' => Http :: response ([
2026-01-18 18:56:51 +00:00
'data' => [
[
'id' => 'page_1' ,
'name' => 'Page 1' ,
'username' => 'page1' ,
'picture' => [ 'data' => [ 'url' => null ]],
'access_token' => 'token-1' ,
],
[
'id' => 'page_2' ,
'name' => 'Page 2' ,
'username' => 'page2' ,
'picture' => [ 'data' => [ 'url' => null ]],
'access_token' => 'token-2' ,
],
],
], 200 ),
]);
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> get ( route ( 'app.social.facebook.callback' ));
2026-01-18 18:56:51 +00:00
2026-03-29 22:24:28 +00:00
$response -> assertRedirect ( route ( 'app.social.facebook.select-page' ));
2026-01-18 18:56:51 +00:00
expect ( session ( 'facebook_oauth' )) -> not -> toBeNull ();
});
test ( 'facebook callback fails when no pages found' , function () {
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' )
2026-04-02 20:57:06 +00:00
-> andReturn ( Mockery :: mock () -> shouldReceive ( 'usingGraphVersion' ) -> andReturnSelf () -> shouldReceive ( 'user' ) -> andReturn ( $socialiteUser ) -> getMock ());
2026-01-18 18:56:51 +00:00
Http :: fake ([
2026-04-02 20:57:06 +00:00
'https://graph.facebook.com/*/me/accounts*' => Http :: response ([
2026-01-18 18:56:51 +00:00
'data' => [],
], 200 ),
]);
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> get ( route ( 'app.social.facebook.callback' ));
2026-01-18 18:56:51 +00:00
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , false ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'message' , 'No Facebook Pages found. You need to be an admin of at least one page.' ));
2026-01-18 18:56:51 +00:00
});
test ( 'facebook callback fails with expired session' , function () {
// No session data - simulating expired session
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> get ( route ( 'app.social.facebook.callback' ));
2026-01-18 18:56:51 +00:00
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , false ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'message' , 'Session expired. Please try again.' ));
2026-01-18 18:56:51 +00:00
});
feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 23:40:03 +00:00
test ( 'user can connect multiple facebook accounts in self-hosted mode' , function () {
config ([ 'trypost.self_hosted' => true ]);
2026-01-18 18:56:51 +00:00
SocialAccount :: factory () -> facebook () -> create ([
'workspace_id' => $this -> workspace -> id ,
'platform_user_id' => 'page_existing' ,
]);
session ([
'social_connect_workspace' => $this -> workspace -> id ,
]);
$socialiteUser = Mockery :: mock ( SocialiteUser :: class );
$socialiteUser -> shouldReceive ( 'getId' ) -> andReturn ( 'facebook_user_456' );
$socialiteUser -> token = 'new-user-token' ;
Socialite :: shouldReceive ( 'driver' )
-> with ( 'facebook' )
2026-04-02 20:57:06 +00:00
-> andReturn ( Mockery :: mock () -> shouldReceive ( 'usingGraphVersion' ) -> andReturnSelf () -> shouldReceive ( 'user' ) -> andReturn ( $socialiteUser ) -> getMock ());
2026-01-18 18:56:51 +00:00
Http :: fake ([
2026-04-02 20:57:06 +00:00
'https://graph.facebook.com/*/me/accounts*' => Http :: response ([
2026-01-18 18:56:51 +00:00
'data' => [
[
'id' => 'page_new' ,
'name' => 'New Page' ,
'picture' => [ 'data' => [ 'url' => null ]],
'access_token' => 'page-token' ,
],
],
], 200 ),
]);
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> get ( route ( 'app.social.facebook.callback' ));
2026-01-18 18:56:51 +00:00
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , true ));
2026-01-18 18:56:51 +00:00
2026-04-14 22:48:35 +00:00
expect ( $this -> workspace -> socialAccounts () -> where ( 'platform' , Platform :: Facebook ) -> count ()) -> toBe ( 2 );
2026-01-18 18:56:51 +00:00
});
test ( 'facebook callback handles oauth errors gracefully' , function () {
session ([
'social_connect_workspace' => $this -> workspace -> id ,
]);
$mock = Mockery :: mock ();
2026-03-29 22:24:28 +00:00
$mock -> shouldReceive ( 'user' ) -> andThrow ( new Exception ( 'OAuth error' ));
2026-01-18 18:56:51 +00:00
Socialite :: shouldReceive ( 'driver' )
-> with ( 'facebook' )
-> andReturn ( $mock );
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> get ( route ( 'app.social.facebook.callback' ));
2026-01-18 18:56:51 +00:00
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , false ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'message' , 'Error connecting account. Please try again.' ));
2026-01-18 18:56:51 +00:00
});
test ( 'facebook page selection creates account' , function () {
session ([
'social_connect_workspace' => $this -> workspace -> id ,
'facebook_oauth' => [
'user_token' => 'test-user-token' ,
'user_id' => 'facebook_user_123' ,
'pages' => [
[
'id' => 'page_123' ,
'name' => 'My Facebook Page' ,
'username' => 'myfbpage' ,
'picture' => null ,
'access_token' => 'page-access-token' ,
],
[
'id' => 'page_456' ,
'name' => 'Other Page' ,
'username' => 'otherpage' ,
'picture' => null ,
'access_token' => 'other-page-token' ,
],
],
],
]);
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> post ( route ( 'app.social.facebook.select' ), [
2026-01-18 18:56:51 +00:00
'page_id' => 'page_123' ,
]);
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , true ));
2026-01-18 18:56:51 +00:00
$this -> assertDatabaseHas ( 'social_accounts' , [
'workspace_id' => $this -> workspace -> id ,
'platform' => Platform :: Facebook -> value ,
'platform_user_id' => 'page_123' ,
'username' => 'myfbpage' ,
]);
});
test ( 'facebook page selection fails with expired session' , function () {
// No session data
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> post ( route ( 'app.social.facebook.select' ), [
2026-01-18 18:56:51 +00:00
'page_id' => 'page_123' ,
]);
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , false ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'message' , 'Session expired. Please try again.' ));
2026-01-18 18:56:51 +00:00
});
test ( 'facebook page selection fails with invalid page id' , function () {
session ([
'social_connect_workspace' => $this -> workspace -> id ,
'facebook_oauth' => [
'user_token' => 'test-user-token' ,
'user_id' => 'facebook_user_123' ,
'pages' => [
[
'id' => 'page_123' ,
'name' => 'My Facebook Page' ,
'picture' => null ,
'access_token' => 'page-access-token' ,
],
],
],
]);
2026-03-29 22:24:28 +00:00
$response = $this -> actingAs ( $this -> user ) -> post ( route ( 'app.social.facebook.select' ), [
2026-01-18 18:56:51 +00:00
'page_id' => 'invalid_page_id' ,
]);
$response -> assertOk ();
2026-06-25 16:54:16 +00:00
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'success' , false ));
$response -> assertInertia ( fn ( AssertableInertia $page ) => $page -> where ( 'message' , 'Page not found.' ));
2026-01-18 18:56:51 +00:00
});