Coverage for tests / unit / test_api.py: 100%

167 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-02 13:07 +0100

1""" 

2Unit tests for the Superset API client. 

3""" 

4 

5import io 

6import zipfile 

7from unittest.mock import Mock 

8 

9import pytest 

10import requests 

11 

12from superset_io.api import SuperSetApiClient 

13 

14 

15def _resp(*, status_code=200, text="", json_data=None, raise_exc=None): 

16 r = Mock() 

17 r.status_code = status_code 

18 r.text = text 

19 r.json = Mock(return_value=json_data if json_data is not None else {}) 

20 if raise_exc is None: 

21 r.raise_for_status = Mock() 

22 else: 

23 r.raise_for_status = Mock(side_effect=raise_exc) 

24 return r 

25 

26 

27def _http_error(msg="http error", *, response_text=""): 

28 err = requests.HTTPError(msg) 

29 if response_text: 

30 err.response = Mock() 

31 err.response.text = response_text 

32 return err 

33 

34 

35class TestAPITestConnection: 

36 """Lean unit tests for request-building logic in SuperSetApiClient.""" 

37 

38 def test_connection_health_fails(self): 

39 session = Mock() 

40 session.base_url = "http://localhost:8088" 

41 session.headers = {"X-CSRFToken": "csrf"} # shouldn't matter; should exit early 

42 

43 session.get = Mock( 

44 side_effect=[ 

45 _resp(raise_exc=_http_error("health down", response_text="nope")) 

46 ] 

47 ) 

48 session.post = Mock() 

49 

50 client = SuperSetApiClient(session) 

51 assert client.test_connection() is False 

52 

53 session.get.assert_called_once_with("/health") 

54 session.post.assert_not_called() 

55 

56 def test_connection_log_fails(self): 

57 session = Mock() 

58 session.base_url = "http://localhost:8088" 

59 session.headers = {"X-CSRFToken": "csrf"} 

60 

61 session.get = Mock( 

62 side_effect=[ 

63 _resp(), # /health ok 

64 _resp( 

65 status_code=401, raise_exc=_http_error("unauthorized") 

66 ), # /api/v1/log/ fails 

67 ] 

68 ) 

69 session.post = Mock() 

70 

71 client = SuperSetApiClient(session) 

72 assert client.test_connection() is False 

73 

74 assert session.get.call_args_list[0].args[0] == "/health" 

75 assert session.get.call_args_list[1].args[0] == "/api/v1/log/" 

76 session.post.assert_not_called() 

77 

78 def test_connection_missing_csrf_header(self): 

79 session = Mock() 

80 session.base_url = "http://localhost:8088" 

81 session.headers = {} # no X-CSRFToken 

82 

83 session.get = Mock(side_effect=[_resp(), _resp()]) 

84 session.post = Mock() 

85 

86 client = SuperSetApiClient(session) 

87 assert client.test_connection() is False 

88 

89 session.post.assert_not_called() 

90 

91 def test_connection_succeeds_with_invalid_payload(self): 

92 session = Mock() 

93 session.base_url = "http://localhost:8088/" 

94 session.headers = {"X-CSRFToken": "csrf"} 

95 

96 session.get = Mock(side_effect=[_resp(), _resp()]) 

97 

98 post_res = _resp( 

99 status_code=422, 

100 text="unprocessable entity", 

101 json_data={"errors": [{"error_type": "INVALID_PAYLOAD_FORMAT_ERROR"}]}, 

102 raise_exc=_http_error("422"), 

103 ) 

104 session.post = Mock(return_value=post_res) 

105 

106 client = SuperSetApiClient(session) 

107 assert client.test_connection() is True 

108 

109 def test_connection_error_type_not_expected(self): 

110 """ 

111 This covers the 'POST fails and JSON doesn't match heuristic' => should return 

112 False. 

113 """ 

114 session = Mock() 

115 session.base_url = "http://localhost:8088" 

116 session.headers = {"X-CSRFToken": "csrf"} 

117 

118 session.get = Mock(side_effect=[_resp(), _resp()]) 

119 

120 post_res = _resp( 

121 status_code=403, 

122 text="forbidden", 

123 json_data={"errors": [{"error_type": "SOME_OTHER_ERROR"}]}, 

124 raise_exc=_http_error("403"), 

125 ) 

126 session.post = Mock(return_value=post_res) 

127 

128 client = SuperSetApiClient(session) 

129 

130 # If your current code has the `e` NameError, this will raise. 

131 # Once code is fixed, it should simply be False. 

132 

133 assert client.test_connection() is False 

134 

135 def test_connection_post_succeeds(self): 

136 session = Mock() 

137 session.base_url = "http://localhost:8088" 

138 session.headers = {"X-CSRFToken": "csrf"} 

139 

140 session.get = Mock(side_effect=[_resp(), _resp()]) 

141 session.post = Mock(return_value=_resp(status_code=200)) 

142 

143 client = SuperSetApiClient(session) 

144 assert client.test_connection() is True 

145 

146 session.post.assert_called_once_with( 

147 "/api/v1/assets/import/", 

148 json={}, 

149 headers={"Referer": "http://localhost:8088/"}, 

150 ) 

151 

152 

153class TestUploadAssets: 

154 @pytest.fixture 

155 def asset_folder(self, tmp_path): 

156 assets_root = tmp_path / "assets_folder" 

157 (assets_root / "dashboards").mkdir(parents=True) 

158 

159 (assets_root / "metadata.yaml").write_text("version: 1.0", encoding="utf-8") 

160 (assets_root / "dashboards" / "demo.yaml").write_text( 

161 "dashboard_title: Demo", encoding="utf-8" 

162 ) 

163 yield assets_root 

164 

165 @pytest.fixture 

166 def client(self): 

167 session = Mock() 

168 session.base_url = "http://localhost:8088" 

169 session.headers = {"X-CSRFToken": "csrf"} 

170 return SuperSetApiClient(session) 

171 

172 def test_upload_assets_zip(self, tmp_path, asset_folder, client, monkeypatch): 

173 # Create a real zip on disk from the fixture folder contents 

174 zip_path = tmp_path / "assets_bundle.zip" 

175 with zipfile.ZipFile(zip_path, "w") as zf: 

176 for p in asset_folder.rglob("*"): 

177 if p.is_file(): 

178 zf.write(p, arcname=p.relative_to(asset_folder.parent).as_posix()) 

179 

180 post_mock = Mock() 

181 monkeypatch.setattr(client, "_post_assets", post_mock) 

182 

183 client.upload_assets(zip_path) 

184 

185 post_mock.assert_called_once() 

186 buf = post_mock.call_args.kwargs["zipfile_buffer"] 

187 

188 assert isinstance(buf, io.BytesIO) 

189 assert buf.getvalue() == zip_path.read_bytes() 

190 

191 def test_upload_assets_folder(self, asset_folder, client, monkeypatch): 

192 post_mock = Mock() 

193 monkeypatch.setattr(client, "_post_assets", post_mock) 

194 

195 client.upload_assets(asset_folder) 

196 

197 post_mock.assert_called_once() 

198 buf = post_mock.call_args.kwargs["zipfile_buffer"] 

199 

200 assert isinstance(buf, io.BytesIO) 

201 # ensure zipped structure contains expected files 

202 with zipfile.ZipFile(buf, "r") as zf: 

203 names = zf.namelist() 

204 assert "assets_folder/metadata.yaml" in names 

205 assert "assets_folder/dashboards/demo.yaml" in names 

206 

207 

208class TestDownloadAssets: 

209 @pytest.fixture 

210 def client(self): 

211 session = Mock() 

212 session.base_url = "http://localhost:8088" 

213 session.headers = {"X-CSRFToken": "csrf"} 

214 return SuperSetApiClient(session) 

215 

216 def _zip_bytes(self, files: dict[str, str]) -> bytes: 

217 buf = io.BytesIO() 

218 with zipfile.ZipFile(buf, "w") as zf: 

219 for name, content in files.items(): 

220 zf.writestr(name, content) 

221 return buf.getvalue() 

222 

223 def test_download_assets_writes_zip_file(self, tmp_path, client, monkeypatch): 

224 zip_bytes = self._zip_bytes( 

225 { 

226 "assets_export_123/metadata.yaml": "version: 1.0", 

227 "assets_export_123/dashboards/demo.yaml": "dashboard_title: Demo", 

228 } 

229 ) 

230 zip_file = zipfile.ZipFile(io.BytesIO(zip_bytes), "r") 

231 monkeypatch.setattr( 

232 client, "_get_assets_zip", Mock(return_value=(zip_bytes, zip_file)) 

233 ) 

234 

235 out_zip = tmp_path / "out.zip" 

236 client.download_assets(out_zip) 

237 

238 assert out_zip.exists() 

239 assert out_zip.read_bytes() == zip_bytes 

240 

241 def test_download_assets_extracts_to_folder_and_moves_children( 

242 self, tmp_path, client, monkeypatch 

243 ): 

244 zip_bytes = self._zip_bytes( 

245 { 

246 "assets_export_123/metadata.yaml": "version: 1.0", 

247 "assets_export_123/dashboards/demo.yaml": "dashboard_title: Demo", 

248 } 

249 ) 

250 zip_file = zipfile.ZipFile(io.BytesIO(zip_bytes), "r") 

251 monkeypatch.setattr( 

252 client, "_get_assets_zip", Mock(return_value=(zip_bytes, zip_file)) 

253 ) 

254 

255 out_dir = tmp_path / "out_folder" # no .zip suffix => folder mode 

256 client.download_assets(out_dir) 

257 

258 assert out_dir.is_dir() 

259 # children moved out of assets_export_123/ into out_dir 

260 assert (out_dir / "metadata.yaml").exists() 

261 assert (out_dir / "dashboards" / "demo.yaml").exists() 

262 

263 def test_download_assets_folder_destination_not_empty_raises( 

264 self, tmp_path, client 

265 ): 

266 out_dir = tmp_path / "out_folder" 

267 out_dir.mkdir(parents=True) 

268 (out_dir / "already_there.txt").write_text("x", encoding="utf-8") 

269 

270 with pytest.raises(ValueError, match="is not empty"): 

271 client.download_assets(out_dir) 

272 

273 def test_download_assets_raises_if_no_assets_export_folder_in_zip( 

274 self, tmp_path, client, monkeypatch 

275 ): 

276 zip_bytes = self._zip_bytes( 

277 { 

278 "not_assets_export/metadata.yaml": "version: 1.0", 

279 } 

280 ) 

281 zip_file = zipfile.ZipFile(io.BytesIO(zip_bytes), "r") 

282 monkeypatch.setattr( 

283 client, "_get_assets_zip", Mock(return_value=(zip_bytes, zip_file)) 

284 ) 

285 

286 out_dir = tmp_path / "out_folder" 

287 with pytest.raises( 

288 ValueError, match="Did not find a single `assets_export` folder" 

289 ): 

290 client.download_assets(out_dir) 

291 

292 def test_download_assets_raises_if_multiple_assets_export_folders_in_zip( 

293 self, tmp_path, client, monkeypatch 

294 ): 

295 zip_bytes = self._zip_bytes( 

296 { 

297 "assets_export_111/metadata.yaml": "version: 1.0", 

298 "assets_export_222/metadata.yaml": "version: 1.0", 

299 } 

300 ) 

301 zip_file = zipfile.ZipFile(io.BytesIO(zip_bytes), "r") 

302 monkeypatch.setattr( 

303 client, "_get_assets_zip", Mock(return_value=(zip_bytes, zip_file)) 

304 ) 

305 

306 out_dir = tmp_path / "out_folder" 

307 with pytest.raises( 

308 ValueError, match="Did not find a single `assets_export` folder" 

309 ): 

310 client.download_assets(out_dir)