Coverage for tests / unit / utils / test_node_deps.py: 100%

149 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Unit tests for node_deps utilities.""" 

2 

3from __future__ import annotations 

4 

5import subprocess 

6from pathlib import Path 

7from unittest.mock import MagicMock, patch 

8 

9import pytest 

10from assertpy import assert_that 

11 

12from lintro.utils.node_deps import ( 

13 get_package_manager_command, 

14 install_node_deps, 

15 should_install_deps, 

16) 

17 

18# ============================================================================= 

19# Tests for should_install_deps 

20# ============================================================================= 

21 

22 

23def test_should_install_deps_returns_false_when_no_package_json( 

24 tmp_path: Path, 

25) -> None: 

26 """Return False when package.json doesn't exist.""" 

27 result = should_install_deps(tmp_path) 

28 

29 assert_that(result).is_false() 

30 

31 

32def test_should_install_deps_returns_true_when_package_json_exists_no_node_modules( 

33 tmp_path: Path, 

34) -> None: 

35 """Return True when package.json exists but node_modules is missing.""" 

36 (tmp_path / "package.json").write_text("{}") 

37 

38 result = should_install_deps(tmp_path) 

39 

40 assert_that(result).is_true() 

41 

42 

43def test_should_install_deps_returns_false_when_both_exist_with_content( 

44 tmp_path: Path, 

45) -> None: 

46 """Return False when both package.json and node_modules exist with content.""" 

47 (tmp_path / "package.json").write_text("{}") 

48 node_modules = tmp_path / "node_modules" 

49 node_modules.mkdir() 

50 # Add a real package directory (not just .bin) 

51 (node_modules / "lodash").mkdir() 

52 

53 result = should_install_deps(tmp_path) 

54 

55 assert_that(result).is_false() 

56 

57 

58def test_should_install_deps_returns_true_when_node_modules_empty( 

59 tmp_path: Path, 

60) -> None: 

61 """Return True when node_modules exists but is empty.""" 

62 (tmp_path / "package.json").write_text("{}") 

63 (tmp_path / "node_modules").mkdir() 

64 

65 result = should_install_deps(tmp_path) 

66 

67 assert_that(result).is_true() 

68 

69 

70def test_should_install_deps_returns_true_when_node_modules_only_has_bin( 

71 tmp_path: Path, 

72) -> None: 

73 """Return True when node_modules only contains .bin directory.""" 

74 (tmp_path / "package.json").write_text("{}") 

75 node_modules = tmp_path / "node_modules" 

76 node_modules.mkdir() 

77 (node_modules / ".bin").mkdir() 

78 

79 result = should_install_deps(tmp_path) 

80 

81 assert_that(result).is_true() 

82 

83 

84def test_should_install_deps_raises_permission_error_when_cwd_not_writable( 

85 tmp_path: Path, 

86) -> None: 

87 """Raise PermissionError when package.json exists but directory is not writable.""" 

88 (tmp_path / "package.json").write_text("{}") 

89 

90 with ( 

91 patch("lintro.utils.node_deps.os.access", return_value=False), 

92 pytest.raises(PermissionError, match="not writable"), 

93 ): 

94 should_install_deps(tmp_path) 

95 

96 

97# ============================================================================= 

98# Tests for get_package_manager_command 

99# ============================================================================= 

100 

101 

102def test_get_package_manager_command_returns_bun_when_available() -> None: 

103 """Return bun install with --ignore-scripts when bun is available.""" 

104 with patch("lintro.utils.node_deps.shutil.which") as mock_which: 

105 mock_which.side_effect = lambda x: "/usr/bin/bun" if x == "bun" else None 

106 

107 result = get_package_manager_command() 

108 

109 assert_that(result).is_equal_to(["bun", "install", "--ignore-scripts"]) 

110 

111 

112def test_get_package_manager_command_returns_npm_when_bun_not_available() -> None: 

113 """Return npm install with --ignore-scripts when bun is not available but npm is.""" 

114 with patch("lintro.utils.node_deps.shutil.which") as mock_which: 

115 mock_which.side_effect = lambda x: "/usr/bin/npm" if x == "npm" else None 

116 

117 result = get_package_manager_command() 

118 

119 assert_that(result).is_equal_to(["npm", "install", "--ignore-scripts"]) 

120 

121 

122def test_get_package_manager_command_returns_none_when_no_package_manager() -> None: 

123 """Return None when no package manager is available.""" 

124 with patch("lintro.utils.node_deps.shutil.which", return_value=None): 

125 result = get_package_manager_command() 

126 

127 assert_that(result).is_none() 

128 

129 

130# ============================================================================= 

131# Tests for install_node_deps 

132# ============================================================================= 

133 

134 

135def test_install_node_deps_returns_success_when_deps_already_installed( 

136 tmp_path: Path, 

137) -> None: 

138 """Return success when dependencies are already installed.""" 

139 (tmp_path / "package.json").write_text("{}") 

140 node_modules = tmp_path / "node_modules" 

141 node_modules.mkdir() 

142 (node_modules / "lodash").mkdir() 

143 

144 success, output = install_node_deps(tmp_path) 

145 

146 assert_that(success).is_true() 

147 assert_that(output).contains("already installed") 

148 

149 

150def test_install_node_deps_returns_failure_when_cwd_not_writable( 

151 tmp_path: Path, 

152) -> None: 

153 """Return failure when directory is not writable (PermissionError from should_install_deps).""" 

154 (tmp_path / "package.json").write_text("{}") 

155 

156 with patch("lintro.utils.node_deps.os.access", return_value=False): 

157 success, output = install_node_deps(tmp_path) 

158 

159 assert_that(success).is_false() 

160 assert_that(output).contains("not writable") 

161 

162 

163def test_install_node_deps_returns_failure_when_no_package_manager( 

164 tmp_path: Path, 

165) -> None: 

166 """Return failure when no package manager is available.""" 

167 (tmp_path / "package.json").write_text("{}") 

168 

169 with patch("lintro.utils.node_deps.shutil.which", return_value=None): 

170 success, output = install_node_deps(tmp_path) 

171 

172 assert_that(success).is_false() 

173 assert_that(output).contains("No package manager found") 

174 

175 

176def test_install_node_deps_runs_bun_install_with_frozen_lockfile( 

177 tmp_path: Path, 

178) -> None: 

179 """Try bun install with frozen lockfile first.""" 

180 (tmp_path / "package.json").write_text("{}") 

181 

182 mock_result = MagicMock() 

183 mock_result.returncode = 0 

184 mock_result.stdout = "Installed packages" 

185 mock_result.stderr = "" 

186 

187 with ( 

188 patch( 

189 "lintro.utils.node_deps.shutil.which", 

190 side_effect=lambda x: "/usr/bin/bun" if x == "bun" else None, 

191 ), 

192 patch( 

193 "lintro.utils.node_deps.subprocess.run", 

194 return_value=mock_result, 

195 ) as mock_run, 

196 ): 

197 success, output = install_node_deps(tmp_path) 

198 

199 assert_that(success).is_true() 

200 # Verify frozen lockfile was attempted 

201 call_args = mock_run.call_args_list[0] 

202 assert_that(call_args[0][0]).contains("--frozen-lockfile") 

203 

204 

205def test_install_node_deps_falls_back_to_regular_install_on_frozen_failure( 

206 tmp_path: Path, 

207) -> None: 

208 """Fall back to regular install when frozen lockfile fails.""" 

209 (tmp_path / "package.json").write_text("{}") 

210 

211 frozen_result = MagicMock() 

212 frozen_result.returncode = 1 

213 frozen_result.stderr = "lockfile error" 

214 

215 regular_result = MagicMock() 

216 regular_result.returncode = 0 

217 regular_result.stdout = "Installed" 

218 regular_result.stderr = "" 

219 

220 with ( 

221 patch( 

222 "lintro.utils.node_deps.shutil.which", 

223 side_effect=lambda x: "/usr/bin/bun" if x == "bun" else None, 

224 ), 

225 patch( 

226 "lintro.utils.node_deps.subprocess.run", 

227 side_effect=[frozen_result, regular_result], 

228 ) as mock_run, 

229 ): 

230 success, output = install_node_deps(tmp_path) 

231 

232 assert_that(success).is_true() 

233 # Verify both attempts were made 

234 assert_that(mock_run.call_count).is_equal_to(2) 

235 

236 

237def test_install_node_deps_returns_failure_on_install_error( 

238 tmp_path: Path, 

239) -> None: 

240 """Return failure when both frozen and regular installation fail.""" 

241 (tmp_path / "package.json").write_text("{}") 

242 

243 failed_result = MagicMock() 

244 failed_result.returncode = 1 

245 failed_result.stdout = "" 

246 failed_result.stderr = "npm ERR! network error" 

247 

248 with ( 

249 patch( 

250 "lintro.utils.node_deps.shutil.which", 

251 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None, 

252 ), 

253 patch( 

254 "lintro.utils.node_deps.subprocess.run", 

255 return_value=failed_result, 

256 ) as mock_run, 

257 ): 

258 success, output = install_node_deps(tmp_path) 

259 

260 assert_that(success).is_false() 

261 assert_that(output).contains("network error") 

262 # Verify both npm ci and npm install were attempted 

263 assert_that(mock_run.call_count).is_equal_to(2) 

264 

265 

266def test_install_node_deps_retries_on_frozen_timeout(tmp_path: Path) -> None: 

267 """Retry with regular install when frozen install times out.""" 

268 (tmp_path / "package.json").write_text("{}") 

269 

270 regular_result = MagicMock() 

271 regular_result.returncode = 0 

272 regular_result.stdout = "Installed" 

273 regular_result.stderr = "" 

274 

275 with ( 

276 patch( 

277 "lintro.utils.node_deps.shutil.which", 

278 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None, 

279 ), 

280 patch( 

281 "lintro.utils.node_deps.subprocess.run", 

282 side_effect=[ 

283 subprocess.TimeoutExpired(cmd="npm ci", timeout=120), 

284 regular_result, 

285 ], 

286 ) as mock_run, 

287 ): 

288 success, output = install_node_deps(tmp_path, timeout=120) 

289 

290 assert_that(success).is_true() 

291 assert_that(mock_run.call_count).is_equal_to(2) 

292 

293 

294def test_install_node_deps_fails_on_both_attempts_timeout(tmp_path: Path) -> None: 

295 """Return failure when both frozen and regular install time out.""" 

296 (tmp_path / "package.json").write_text("{}") 

297 

298 with ( 

299 patch( 

300 "lintro.utils.node_deps.shutil.which", 

301 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None, 

302 ), 

303 patch( 

304 "lintro.utils.node_deps.subprocess.run", 

305 side_effect=subprocess.TimeoutExpired(cmd="npm", timeout=120), 

306 ), 

307 ): 

308 success, output = install_node_deps(tmp_path, timeout=120) 

309 

310 assert_that(success).is_false() 

311 assert_that(output).contains("timed out") 

312 

313 

314def test_install_node_deps_uses_npm_ci_for_frozen_install( 

315 tmp_path: Path, 

316) -> None: 

317 """Use npm ci for frozen install with npm.""" 

318 (tmp_path / "package.json").write_text("{}") 

319 

320 mock_result = MagicMock() 

321 mock_result.returncode = 0 

322 mock_result.stdout = "Installed" 

323 mock_result.stderr = "" 

324 

325 with ( 

326 patch( 

327 "lintro.utils.node_deps.shutil.which", 

328 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None, 

329 ), 

330 patch( 

331 "lintro.utils.node_deps.subprocess.run", 

332 return_value=mock_result, 

333 ) as mock_run, 

334 ): 

335 success, _ = install_node_deps(tmp_path) 

336 

337 assert_that(success).is_true() 

338 # npm ci is the frozen lockfile equivalent for npm 

339 # --ignore-scripts prevents lifecycle script execution for security 

340 call_args = mock_run.call_args_list[0] 

341 assert_that(call_args[0][0]).is_equal_to(["npm", "ci", "--ignore-scripts"]) 

342 

343 

344@pytest.mark.parametrize( 

345 ("package_manager", "frozen_cmd", "regular_cmd"), 

346 [ 

347 ( 

348 "bun", 

349 ["bun", "install", "--ignore-scripts", "--frozen-lockfile"], 

350 ["bun", "install", "--ignore-scripts"], 

351 ), 

352 ( 

353 "npm", 

354 ["npm", "ci", "--ignore-scripts"], 

355 ["npm", "install", "--ignore-scripts"], 

356 ), 

357 ], 

358) 

359def test_install_node_deps_uses_correct_commands_per_package_manager( 

360 tmp_path: Path, 

361 package_manager: str, 

362 frozen_cmd: list[str], 

363 regular_cmd: list[str], 

364) -> None: 

365 """Verify correct frozen and regular commands per package manager.""" 

366 (tmp_path / "package.json").write_text("{}") 

367 

368 # First call (frozen) fails, second call (regular) succeeds 

369 frozen_result = MagicMock() 

370 frozen_result.returncode = 1 

371 frozen_result.stderr = "lockfile error" 

372 

373 regular_result = MagicMock() 

374 regular_result.returncode = 0 

375 regular_result.stdout = "Installed" 

376 regular_result.stderr = "" 

377 

378 with ( 

379 patch( 

380 "lintro.utils.node_deps.shutil.which", 

381 side_effect=lambda x: ( 

382 f"/usr/bin/{package_manager}" if x == package_manager else None 

383 ), 

384 ), 

385 patch( 

386 "lintro.utils.node_deps.subprocess.run", 

387 side_effect=[frozen_result, regular_result], 

388 ) as mock_run, 

389 ): 

390 success, _ = install_node_deps(tmp_path) 

391 

392 assert_that(success).is_true() 

393 assert_that(mock_run.call_count).is_equal_to(2) 

394 # Verify frozen command was tried first 

395 assert_that(mock_run.call_args_list[0][0][0]).is_equal_to(frozen_cmd) 

396 # Verify regular command was used as fallback 

397 assert_that(mock_run.call_args_list[1][0][0]).is_equal_to(regular_cmd)