set('app.url', 'https://bemeal.leonmorival.com'); config()->set('services.strava.client_id', '248440'); config()->set('services.strava.client_secret', 'strava-secret'); config()->set('services.strava.scope', 'read,activity:read'); }); it('returns a strava authorization url for the api callback', function () { Sanctum::actingAs(User::factory()->create()); $response = $this->getJson('/api/strava/authorization-url'); $authorizationUrl = $response ->assertOk() ->json('authorizationUrl'); expect($authorizationUrl)->toStartWith('https://www.strava.com/oauth/mobile/authorize?'); parse_str((string) parse_url($authorizationUrl, PHP_URL_QUERY), $query); expect($query['client_id'])->toBe('248440'); expect($query['redirect_uri'])->toBe('https://bemeal.leonmorival.com/strava/callback'); expect($query['response_type'])->toBe('code'); expect($query['approval_prompt'])->toBe('auto'); expect($query['scope'])->toBe('read,activity:read'); expect($query['state'])->not->toBeEmpty(); }); it('redirects the strava web callback to the mobile app', function () { $query = http_build_query([ 'code' => 'authorization-code', 'scope' => 'read activity:read', 'state' => 'oauth-state', ], '', '&', PHP_QUERY_RFC3986); $this ->get("/strava/callback?{$query}") ->assertRedirect("bowly://strava/callback?{$query}"); }); it('stores strava tokens from an authorization code', function () { Http::preventStrayRequests(); Http::fake([ 'https://www.strava.com/oauth/token' => Http::response([ 'token_type' => 'Bearer', 'expires_at' => now()->addHours(6)->timestamp, 'expires_in' => 21600, 'refresh_token' => 'refresh-token', 'access_token' => 'access-token', 'scope' => 'read activity:read', 'athlete' => [ 'id' => 123456, 'firstname' => 'Leon', ], ]), ]); $user = User::factory()->create(); Sanctum::actingAs($user); $authorizationUrl = $this->getJson('/api/strava/authorization-url')->json('authorizationUrl'); parse_str((string) parse_url($authorizationUrl, PHP_URL_QUERY), $query); $response = $this->postJson('/api/strava/callback', [ 'code' => 'authorization-code', 'scope' => 'read activity:read', 'state' => $query['state'], ]); $response ->assertOk() ->assertJsonPath('data.connected', true) ->assertJsonPath('data.athleteId', 123456) ->assertJsonPath('data.scopes.1', 'activity:read'); $connection = $user->fresh()->stravaConnection; expect($connection)->not->toBeNull(); expect($connection->access_token)->toBe('access-token'); expect($connection->refresh_token)->toBe('refresh-token'); $this->assertDatabaseHas('strava_connections', [ 'user_id' => $user->id, 'athlete_id' => 123456, ]); }); it('syncs strava activities into workouts', function () { Http::preventStrayRequests(); Http::fake([ 'https://www.strava.com/api/v3/athlete/activities*' => Http::response([ [ 'id' => 987654321, 'name' => 'Morning run', 'sport_type' => 'Run', 'moving_time' => 1845, 'calories' => 432.6, 'distance' => 5275.4, 'start_date' => '2026-05-19T07:30:00Z', ], ]), ]); $user = User::factory()->create(); StravaConnection::create([ 'user_id' => $user->id, 'athlete_id' => 123456, 'access_token' => 'access-token', 'refresh_token' => 'refresh-token', 'expires_at' => now()->addHour(), 'scopes' => ['read', 'activity:read'], 'athlete' => ['id' => 123456], ]); Sanctum::actingAs($user); $response = $this->postJson('/api/strava/sync'); $response ->assertOk() ->assertJsonPath('data.importedCount', 1) ->assertJsonPath('data.fetchedCount', 1); $this->assertDatabaseHas('workout_sessions', [ 'user_id' => $user->id, 'source' => WorkoutSource::STRAVA->value, 'external_id' => '987654321', 'type' => 'Run', 'title' => 'Morning run', 'duration_seconds' => 1845, 'calories_burned' => 433, 'distance_meters' => 5275, ]); }); it('syncs strava calories from detailed activities when summaries omit them', function () { Http::preventStrayRequests(); Http::fake([ 'https://www.strava.com/api/v3/athlete/activities*' => Http::response([ [ 'id' => 1122334455, 'name' => 'Evening ride', 'sport_type' => 'Ride', 'moving_time' => 2410, 'distance' => 20500.2, 'start_date' => '2026-05-19T17:45:00Z', ], ]), 'https://www.strava.com/api/v3/activities/1122334455*' => Http::response([ 'id' => 1122334455, 'name' => 'Evening ride', 'sport_type' => 'Ride', 'moving_time' => 2410, 'calories' => 618.4, 'distance' => 20500.2, 'start_date' => '2026-05-19T17:45:00Z', ]), ]); $user = User::factory()->create(); StravaConnection::create([ 'user_id' => $user->id, 'athlete_id' => 123456, 'access_token' => 'access-token', 'refresh_token' => 'refresh-token', 'expires_at' => now()->addHour(), 'scopes' => ['read', 'activity:read'], 'athlete' => ['id' => 123456], ]); Sanctum::actingAs($user); $this ->postJson('/api/strava/sync') ->assertOk() ->assertJsonPath('data.importedCount', 1) ->assertJsonPath('data.fetchedCount', 1); $this->assertDatabaseHas('workout_sessions', [ 'user_id' => $user->id, 'source' => WorkoutSource::STRAVA->value, 'external_id' => '1122334455', 'calories_burned' => 618, ]); }); it('requires an activity read scope before syncing strava activities', function () { $user = User::factory()->create(); StravaConnection::create([ 'user_id' => $user->id, 'athlete_id' => 123456, 'access_token' => 'access-token', 'refresh_token' => 'refresh-token', 'expires_at' => now()->addHour(), 'scopes' => ['read'], 'athlete' => ['id' => 123456], ]); Sanctum::actingAs($user); $this ->postJson('/api/strava/sync') ->assertUnprocessable() ->assertJsonPath('message', __('api.strava.missing_activity_scope')); });