Repository: ansible-collections/community.general

Issue:
npm: version specific updates are broken
### Summary

Reported by @shaharmor in https://github.com/ansible-collections/community.general/pull/1625#discussion_r579845916 about #1625:

>  This change breaks all version specific updates because this was the way npm was checking if a specific version is installed.
> 
> Before:
> `npm list --json --long pkg@1.2.3` would return `{}` if `pkg` was not installed with version `1.2.3`
> 
> After
> `npm list --json --long` would return the full `pkg` info regardless of which version is installed, and this will make ansible think that the module is installed.
 

### Issue Type

Bug Report

### Component Name

npm

### Ansible Version

```console (paste below)
devel
```


### Configuration

_No response_

### OS / Environment

_No response_

### Steps to Reproduce

...

### Expected Results

...

### Actual Results

_No response_

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/packaging/language/npm.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/packaging/language/npm.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @shane-walker @xcambar
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
CC @Akasurde 
Is there any plan to fix this? 
needs_contributor

Resolving patch:
diff --git a/plugins/modules/packaging/language/npm.py b/plugins/modules/packaging/language/npm.py
--- a/plugins/modules/packaging/language/npm.py
+++ b/plugins/modules/packaging/language/npm.py
@@ -181,7 +181,7 @@ def _exec(self, args, run_in_check_mode=False, check_rc=True, add_package_name=T
                 cmd.append('--ignore-scripts')
             if self.unsafe_perm:
                 cmd.append('--unsafe-perm')
-            if self.name and add_package_name:
+            if self.name_version and add_package_name:
                 cmd.append(self.name_version)
             if self.registry:
                 cmd.append('--registry')
@@ -215,14 +215,17 @@ def list(self):
         except (getattr(json, 'JSONDecodeError', ValueError)) as e:
             self.module.fail_json(msg="Failed to parse NPM output with error %s" % to_native(e))
         if 'dependencies' in data:
-            for dep in data['dependencies']:
-                if 'missing' in data['dependencies'][dep] and data['dependencies'][dep]['missing']:
+            for dep, props in data['dependencies'].items():
+                dep_version = dep + '@' + str(props['version'])
+
+                if 'missing' in props and props['missing']:
                     missing.append(dep)
-                elif 'invalid' in data['dependencies'][dep] and data['dependencies'][dep]['invalid']:
+                elif 'invalid' in props and props['invalid']:
                     missing.append(dep)
                 else:
                     installed.append(dep)
-            if self.name and self.name not in installed:
+                    installed.append(dep_version)
+            if self.name_version and self.name_version not in installed:
                 missing.append(self.name)
         # Named dependency not installed
         else:


Test patch:
diff --git a/tests/unit/plugins/modules/packaging/language/test_npm.py b/tests/unit/plugins/modules/packaging/language/test_npm.py
--- a/tests/unit/plugins/modules/packaging/language/test_npm.py
+++ b/tests/unit/plugins/modules/packaging/language/test_npm.py
@@ -47,6 +47,66 @@ def test_present(self):
         result = self.module_main(AnsibleExitJson)
 
         self.assertTrue(result['changed'])
+        self.module_main_command.assert_has_calls([
+            call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
+            call(['/testbin/npm', 'install', '--global', 'coffee-script'], check_rc=True, cwd=None),
+        ])
+
+    def test_present_version(self):
+        set_module_args({
+            'name': 'coffee-script',
+            'global': 'true',
+            'state': 'present',
+            'version': '2.5.1'
+        })
+        self.module_main_command.side_effect = [
+            (0, '{}', ''),
+            (0, '{}', ''),
+        ]
+
+        result = self.module_main(AnsibleExitJson)
+
+        self.assertTrue(result['changed'])
+        self.module_main_command.assert_has_calls([
+            call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
+            call(['/testbin/npm', 'install', '--global', 'coffee-script@2.5.1'], check_rc=True, cwd=None),
+        ])
+
+    def test_present_version_update(self):
+        set_module_args({
+            'name': 'coffee-script',
+            'global': 'true',
+            'state': 'present',
+            'version': '2.5.1'
+        })
+        self.module_main_command.side_effect = [
+            (0, '{"dependencies": {"coffee-script": {"version" : "2.5.0"}}}', ''),
+            (0, '{}', ''),
+        ]
+
+        result = self.module_main(AnsibleExitJson)
+
+        self.assertTrue(result['changed'])
+        self.module_main_command.assert_has_calls([
+            call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
+            call(['/testbin/npm', 'install', '--global', 'coffee-script@2.5.1'], check_rc=True, cwd=None),
+        ])
+
+    def test_present_version_exists(self):
+        set_module_args({
+            'name': 'coffee-script',
+            'global': 'true',
+            'state': 'present',
+            'version': '2.5.1'
+        })
+        self.module_main_command.side_effect = [
+            (0, '{"dependencies": {"coffee-script": {"version" : "2.5.1"}}}', ''),
+            (0, '{}', ''),
+        ]
+
+        result = self.module_main(AnsibleExitJson)
+
+        self.assertFalse(result['changed'])
         self.module_main_command.assert_has_calls([
             call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
         ])
@@ -58,7 +118,7 @@ def test_absent(self):
             'state': 'absent'
         })
         self.module_main_command.side_effect = [
-            (0, '{"dependencies": {"coffee-script": {}}}', ''),
+            (0, '{"dependencies": {"coffee-script": {"version" : "2.5.1"}}}', ''),
             (0, '{}', ''),
         ]
 
@@ -66,5 +126,46 @@ def test_absent(self):
 
         self.assertTrue(result['changed'])
         self.module_main_command.assert_has_calls([
+            call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
+            call(['/testbin/npm', 'uninstall', '--global', 'coffee-script'], check_rc=True, cwd=None),
+        ])
+
+    def test_absent_version(self):
+        set_module_args({
+            'name': 'coffee-script',
+            'global': 'true',
+            'state': 'absent',
+            'version': '2.5.1'
+        })
+        self.module_main_command.side_effect = [
+            (0, '{"dependencies": {"coffee-script": {"version" : "2.5.1"}}}', ''),
+            (0, '{}', ''),
+        ]
+
+        result = self.module_main(AnsibleExitJson)
+
+        self.assertTrue(result['changed'])
+        self.module_main_command.assert_has_calls([
+            call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
+            call(['/testbin/npm', 'uninstall', '--global', 'coffee-script'], check_rc=True, cwd=None),
+        ])
+
+    def test_absent_version_different(self):
+        set_module_args({
+            'name': 'coffee-script',
+            'global': 'true',
+            'state': 'absent',
+            'version': '2.5.1'
+        })
+        self.module_main_command.side_effect = [
+            (0, '{"dependencies": {"coffee-script": {"version" : "2.5.0"}}}', ''),
+            (0, '{}', ''),
+        ]
+
+        result = self.module_main(AnsibleExitJson)
+
+        self.assertTrue(result['changed'])
+        self.module_main_command.assert_has_calls([
+            call(['/testbin/npm', 'list', '--json', '--long', '--global'], check_rc=False, cwd=None),
             call(['/testbin/npm', 'uninstall', '--global', 'coffee-script'], check_rc=True, cwd=None),
         ])