Coverage for tests / test_toardb.py: 100%
211 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 12:51 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 12:51 +0000
1# SPDX-FileCopyrightText: 2021 Forschungszentrum Jülich GmbH
2# SPDX-License-Identifier: MIT
4import pytest
5import json
6# Required imports 'create_test_database'
7import csv
8from sqlalchemy import insert, MetaData
9from toardb.test_base import (
10 client,
11 get_test_db,
12 create_test_database,
13 url,
14 get_test_engine,
15 test_db_session as db,
16)
17from toardb.stationmeta.models import StationmetaCore, StationmetaGlobal, StationmetaGlobalService
18from toardb.auth_user.models import AuthUser
19from toardb.contacts.models import Person, Organisation, Contact
20from toardb.variables.models import Variable
21from toardb.stationmeta.schemas import get_geom_from_coordinates, Coordinates
22from toardb.timeseries.models import Timeseries, timeseries_timeseries_roles_table
23from toardb.timeseries.models_programme import TimeseriesProgramme
24from toardb.timeseries.models_role import TimeseriesRole
25from toardb.data.models import Data
28class TestApps:
29 def setup(self):
30 self.application_url = "/controlled_vocabulary"
33 @pytest.fixture(autouse=True)
34 def setup_db_data(self, db):
35 _db_conn = get_test_engine()
36 fake_conn = _db_conn.raw_connection()
37 fake_cur = fake_conn.cursor()
38 # id_seq will not be reset automatically between tests!
39 fake_cur.execute("ALTER SEQUENCE auth_user_id_seq RESTART WITH 1")
40 fake_conn.commit()
41 fake_cur.execute("ALTER SEQUENCE variables_id_seq RESTART WITH 1")
42 fake_conn.commit()
43 fake_cur.execute("ALTER SEQUENCE stationmeta_core_id_seq RESTART WITH 1")
44 fake_conn.commit()
45 fake_cur.execute("ALTER SEQUENCE stationmeta_global_id_seq RESTART WITH 1")
46 fake_conn.commit()
47 fake_cur.execute("ALTER SEQUENCE stationmeta_roles_id_seq RESTART WITH 1")
48 fake_conn.commit()
49 fake_cur.execute("ALTER SEQUENCE stationmeta_annotations_id_seq RESTART WITH 1")
50 fake_conn.commit()
51 fake_cur.execute("ALTER SEQUENCE stationmeta_aux_doc_id_seq RESTART WITH 1")
52 fake_conn.commit()
53 fake_cur.execute("ALTER SEQUENCE stationmeta_aux_image_id_seq RESTART WITH 1")
54 fake_conn.commit()
55 fake_cur.execute("ALTER SEQUENCE stationmeta_aux_url_id_seq RESTART WITH 1")
56 fake_conn.commit()
57 fake_cur.execute("ALTER SEQUENCE persons_id_seq RESTART WITH 1")
58 fake_conn.commit()
59 fake_cur.execute("ALTER SEQUENCE organisations_id_seq RESTART WITH 1")
60 fake_conn.commit()
61 fake_cur.execute("ALTER SEQUENCE contacts_id_seq RESTART WITH 1")
62 fake_conn.commit()
63 fake_cur.execute("ALTER SEQUENCE timeseries_annotations_id_seq RESTART WITH 1")
64 fake_conn.commit()
65 fake_cur.execute("ALTER SEQUENCE timeseries_roles_id_seq RESTART WITH 3")
66 fake_conn.commit()
67 fake_cur.execute("ALTER SEQUENCE timeseries_programmes_id_seq RESTART WITH 1")
68 fake_conn.commit()
69 fake_cur.execute("ALTER SEQUENCE timeseries_id_seq RESTART WITH 1")
70 fake_conn.commit()
71 infilename = "tests/fixtures/stationmeta/stationmeta_global_services.json"
72 with open(infilename) as f:
73 metajson=json.load(f)
74 for entry in metajson:
75 new_stationmeta_global_service = StationmetaGlobalService(**entry)
76 db.add(new_stationmeta_global_service)
77 db.commit()
78 db.refresh(new_stationmeta_global_service)
79 infilename = "tests/fixtures/auth_user/auth.json"
80 with open(infilename) as f:
81 metajson=json.load(f)
82 for entry in metajson:
83 new_auth_user = AuthUser(**entry)
84 db.add(new_auth_user)
85 db.commit()
86 db.refresh(new_auth_user)
87 infilename = "tests/fixtures/contacts/persons.json"
88 with open(infilename) as f:
89 metajson=json.load(f)
90 for entry in metajson:
91 new_person = Person(**entry)
92 db.add(new_person)
93 db.commit()
94 db.refresh(new_person)
95 infilename = "tests/fixtures/contacts/organisations.json"
96 with open(infilename) as f:
97 metajson=json.load(f)
98 for entry in metajson:
99 new_organisation = Organisation(**entry)
100 db.add(new_organisation)
101 db.commit()
102 db.refresh(new_organisation)
103 infilename = "tests/fixtures/contacts/contacts.json"
104 with open(infilename) as f:
105 metajson=json.load(f)
106 for entry in metajson:
107 new_contact = Contact(**entry)
108 db.add(new_contact)
109 db.commit()
110 db.refresh(new_contact)
111 infilename = "tests/fixtures/variables/variables.json"
112 with open(infilename) as f:
113 metajson=json.load(f)
114 for entry in metajson:
115 new_variable = Variable(**entry)
116 db.add(new_variable)
117 db.commit()
118 db.refresh(new_variable)
119 infilename = "tests/fixtures/stationmeta/stationmeta_core.json"
120 with open(infilename) as f:
121 metajson=json.load(f)
122 for entry in metajson:
123 new_stationmeta_core = StationmetaCore(**entry)
124 # there's a mismatch with coordinates --> how to automatically switch back and forth?!
125 new_stationmeta_core.coordinates = get_geom_from_coordinates(Coordinates(**new_stationmeta_core.coordinates))
126 db.add(new_stationmeta_core)
127 db.commit()
128 db.refresh(new_stationmeta_core)
129 infilename = "tests/fixtures/stationmeta/stationmeta_global.json"
130 with open(infilename) as f:
131 metajson=json.load(f)
132 for entry in metajson:
133 new_stationmeta_global = StationmetaGlobal(**entry)
134 db.add(new_stationmeta_global)
135 db.commit()
136 db.refresh(new_stationmeta_global)
137 infilename = "tests/fixtures/timeseries/timeseries_programmes.json"
138 with open(infilename) as f:
139 metajson=json.load(f)
140 for entry in metajson:
141 new_timeseries_programme = TimeseriesProgramme(**entry)
142 db.add(new_timeseries_programme)
143 db.commit()
144 db.refresh(new_timeseries_programme)
145 infilename = "tests/fixtures/timeseries/timeseries.json"
146 with open(infilename) as f:
147 metajson=json.load(f)
148 for entry in metajson:
149 new_timeseries = Timeseries(**entry)
150 db.add(new_timeseries)
151 db.commit()
152 db.refresh(new_timeseries)
153 infilename = "tests/fixtures/timeseries/timeseries_roles.json"
154 with open(infilename) as f:
155 metajson=json.load(f)
156 for entry in metajson:
157 new_timeseries_role = TimeseriesRole(**entry)
158 db.add(new_timeseries_role)
159 db.commit()
160 db.refresh(new_timeseries_role)
161 infilename = "tests/fixtures/timeseries/timeseries_timeseries_roles.json"
162 with open(infilename) as f:
163 metajson=json.load(f)
164 for entry in metajson:
165 db.execute(insert(timeseries_timeseries_roles_table).values(timeseries_id=entry["timeseries_id"], role_id=entry["role_id"]))
166 db.execute("COMMIT")
167 infilename = "tests/fixtures/data/data.json"
168 with open(infilename) as f:
169 metajson=json.load(f)
170 for entry in metajson:
171 new_data = Data (**entry)
172 db.add(new_data)
173 db.commit()
174 db.refresh(new_data)
175 infilename = "tests/fixtures/data/staging_data.json"
176 with open(infilename) as f:
177 metajson=json.load(f)
178 for entry in metajson:
179 new_data = Data (**entry)
180 fake_cur.execute(("INSERT INTO staging.data(datetime, value, flags, timeseries_id, version) "
181 f" VALUES('{new_data.datetime}', {new_data.value}, {new_data.flags}, "
182 f"{new_data.timeseries_id}, '{new_data.version}');"))
183 fake_conn.commit()
184 with open("tests/fixtures/data/yearly_coverage.csv", newline="", encoding="utf-8") as f:
185 metadata = MetaData()
186 metadata.reflect(bind=_db_conn)
187 table = metadata.tables["yearly_coverage"]
188 reader = csv.DictReader(f)
189 rows = list(reader)
190 db.execute(insert(table), rows)
191 db.commit()
194 def test_get_controlled_vocabulary(self, client, db):
195 response = client.get("/controlled_vocabulary")
196 expected_status_code = 200
197 assert response.status_code == expected_status_code
198 expected_resp = {"Station Landcover Type":
199 [[-1, 'Undefined', '-1 (undefined)'],
200 [0, 'NoData', '0 (No Data)'],
201 [10, 'CroplandRainfed', '10 (Cropland, rainfed)'],
202 [11, 'CroplandRainfedHerbaceousCover', '11 (Cropland, rainfed, herbaceous cover)'],
203 [12, 'CroplandRainfedTreeOrShrubCover', '12 (Cropland, rainfed, tree or shrub cover)'],
204 [20, 'CroplandIrrigated', '20 (Cropland, irrigated or post-flooding)'],
205 [30, 'MosaicCropland', '30 (Mosaic cropland (>50%) / natural vegetation (tree, shrub, herbaceous cover) (<50%))'],
206 [40, 'MosaicNaturalVegetation', '40 (Mosaic natural vegetation (tree, shrub, herbaceous cover) (>50%) / cropland (<50%))'],
207 [50, 'TreeBroadleavedEvergreenClosedToOpen', '50 (Tree cover, broadleaved, evergreen, closed to open (>15%))'],
208 [60, 'TreeBroadleavedDeciduousClosedToOpen', '60 (Tree cover, broadleaved, deciduous, closed to open (>15%))'],
209 [61, 'TreeBroadleavedDeciduousClosed', '61 (Tree cover, broadleaved, deciduous, closed (>40%))'],
210 [62, 'TreeBroadleavedDeciduousOpen', '62 (Tree cover, broadleaved, deciduous, open (15-40%))'],
211 [70, 'TreeNeedleleavedEvergreenClosedToOpen', '70 (Tree cover, needleleaved, evergreen, closed to open (>15%))'],
212 [71, 'TreeNeedleleavedEvergreenClosed', '71 (Tree cover, needleleaved, evergreen, closed (>40%))'],
213 [72, 'TreeNeedleleavedEvergreenOpen', '72 (Tree cover, needleleaved, evergreen, open (15-40%))'],
214 [80, 'TreeNeedleleavedDeciduousClosedToOpen', '80 (Tree cover, needleleaved, deciduous, closed to open (>15%))'],
215 [81, 'TreeNedleleavedDeciduousClosed', '81 (Tree cover, needleleaved, deciduous, closed (>40%))'],
216 [82, 'TreeNeedleleavedDeciduousOpen', '82 (Tree cover, needleleaved, deciduous, open (15-40%))'],
217 [90, 'TreeMixed', '90 (Tree cover, mixed leaf type (broadleaved and needleleaved))'],
218 [100, 'MosaicTreeAndShrub', '100 (Mosaic tree and shrub (>50%) / herbaceous cover (<50%))'],
219 [110, 'MosaicHerbaceous', '110 (Mosaic herbaceous cover (>50%) / tree and shrub (<50%))'],
220 [120, 'Shrubland', '120 (Shrubland)'],
221 [121, 'ShrublandEvergreen', '121 (Evergreen shrubland)'],
222 [122, 'ShrublandDeciduous', '122 (Deciduous shrubland)'],
223 [130, 'Grassland', '130 (Grassland)'],
224 [140, 'LichensAndMosses', '140 (Lichens and mosses)'],
225 [150, 'SparseVegetation', '150 (Sparse vegetation (tree, shrub, herbaceous cover) (<15%))'],
226 [151, 'SparseTree', '151 (Sparse tree (<15%))'],
227 [152, 'SparseShrub', '152 (Sparse shrub (<15%))'],
228 [153, 'SparseHerbaceous', '153 (Sparse herbaceous cover (<15%))'],
229 [160, 'TreeCoverFloodedFreshOrBrakishWater', '160 (Tree cover, flooded, fresh or brakish water)'],
230 [170, 'TreeCoverFloodedSalineWater', '170 (Tree cover, flooded, saline water)'],
231 [180, 'ShrubOrHerbaceousCoverFlooded', '180 (Shrub or herbaceous cover, flooded, fresh/saline/brakish water)'],
232 [190, 'Urban', '190 (Urban areas)'],
233 [200, 'BareAreas', '200 (Bare areas)'],
234 [201, 'BareAreasConsolidated', '201 (Consolidated bare areas)'],
235 [202, 'BareAreasUnconsolidated', '202 (Unconsolidated bare areas)'],
236 [210, 'Water', '210 (Water bodies)'],
237 [220, 'SnowAndIce', '220 (Permanent snow and ice)']],
238 "Climatic Zone 2019":
239 [[-1,"Undefined","-1 (undefined)"],
240 [0,"Unclassified","0 (unclassified)"],
241 [1, 'TropicalMontane', '1 (tropical montane)'],
242 [2, 'TropicalWet', '2 (tropical wet)'],
243 [3, 'TropicalMoist', '3 (tropical moist)'],
244 [4, 'TropicalDry', '4 (tropical dry)'],
245 [5, 'WarmTemperateMoist', '5 (warm temperate moist)'],
246 [6, 'WarmTemperateDry', '6 (warm temperate dry)'],
247 [7, 'CoolTemperateMoist', '7 (cool temperate moist)'],
248 [8, 'CoolTemperateDry', '8 (cool temperate dry)'],
249 [9, 'BorealMoist', '9 (boreal moist)'],
250 [10, 'BorealDry', '10 (boreal dry)'],
251 [11, 'PolarMoist', '11 (polar moist)'],
252 [12, 'PolarDry', '12 (polar dry)']] }
253 assert response.json()["Station Landcover Type"] == expected_resp["Station Landcover Type"]
254 assert response.json()["Climatic Zone 2019"] == expected_resp["Climatic Zone 2019"]
258 def test_get_controlled_vocabulary_field(self, client, db):
259 response = client.get("/controlled_vocabulary/Station Landcover Type")
260 expected_status_code = 200
261 assert response.status_code == expected_status_code
262 expected_resp = [[-1, 'Undefined', '-1 (undefined)'],
263 [0, 'NoData', '0 (No Data)'],
264 [10, 'CroplandRainfed', '10 (Cropland, rainfed)'],
265 [11, 'CroplandRainfedHerbaceousCover', '11 (Cropland, rainfed, herbaceous cover)'],
266 [12, 'CroplandRainfedTreeOrShrubCover', '12 (Cropland, rainfed, tree or shrub cover)'],
267 [20, 'CroplandIrrigated', '20 (Cropland, irrigated or post-flooding)'],
268 [30, 'MosaicCropland', '30 (Mosaic cropland (>50%) / natural vegetation (tree, shrub, herbaceous cover) (<50%))'],
269 [40, 'MosaicNaturalVegetation', '40 (Mosaic natural vegetation (tree, shrub, herbaceous cover) (>50%) / cropland (<50%))'],
270 [50, 'TreeBroadleavedEvergreenClosedToOpen', '50 (Tree cover, broadleaved, evergreen, closed to open (>15%))'],
271 [60, 'TreeBroadleavedDeciduousClosedToOpen', '60 (Tree cover, broadleaved, deciduous, closed to open (>15%))'],
272 [61, 'TreeBroadleavedDeciduousClosed', '61 (Tree cover, broadleaved, deciduous, closed (>40%))'],
273 [62, 'TreeBroadleavedDeciduousOpen', '62 (Tree cover, broadleaved, deciduous, open (15-40%))'],
274 [70, 'TreeNeedleleavedEvergreenClosedToOpen', '70 (Tree cover, needleleaved, evergreen, closed to open (>15%))'],
275 [71, 'TreeNeedleleavedEvergreenClosed', '71 (Tree cover, needleleaved, evergreen, closed (>40%))'],
276 [72, 'TreeNeedleleavedEvergreenOpen', '72 (Tree cover, needleleaved, evergreen, open (15-40%))'],
277 [80, 'TreeNeedleleavedDeciduousClosedToOpen', '80 (Tree cover, needleleaved, deciduous, closed to open (>15%))'],
278 [81, 'TreeNedleleavedDeciduousClosed', '81 (Tree cover, needleleaved, deciduous, closed (>40%))'],
279 [82, 'TreeNeedleleavedDeciduousOpen', '82 (Tree cover, needleleaved, deciduous, open (15-40%))'],
280 [90, 'TreeMixed', '90 (Tree cover, mixed leaf type (broadleaved and needleleaved))'],
281 [100, 'MosaicTreeAndShrub', '100 (Mosaic tree and shrub (>50%) / herbaceous cover (<50%))'],
282 [110, 'MosaicHerbaceous', '110 (Mosaic herbaceous cover (>50%) / tree and shrub (<50%))'],
283 [120, 'Shrubland', '120 (Shrubland)'],
284 [121, 'ShrublandEvergreen', '121 (Evergreen shrubland)'],
285 [122, 'ShrublandDeciduous', '122 (Deciduous shrubland)'],
286 [130, 'Grassland', '130 (Grassland)'],
287 [140, 'LichensAndMosses', '140 (Lichens and mosses)'],
288 [150, 'SparseVegetation', '150 (Sparse vegetation (tree, shrub, herbaceous cover) (<15%))'],
289 [151, 'SparseTree', '151 (Sparse tree (<15%))'],
290 [152, 'SparseShrub', '152 (Sparse shrub (<15%))'],
291 [153, 'SparseHerbaceous', '153 (Sparse herbaceous cover (<15%))'],
292 [160, 'TreeCoverFloodedFreshOrBrakishWater', '160 (Tree cover, flooded, fresh or brakish water)'],
293 [170, 'TreeCoverFloodedSalineWater', '170 (Tree cover, flooded, saline water)'],
294 [180, 'ShrubOrHerbaceousCoverFlooded', '180 (Shrub or herbaceous cover, flooded, fresh/saline/brakish water)'],
295 [190, 'Urban', '190 (Urban areas)'],
296 [200, 'BareAreas', '200 (Bare areas)'],
297 [201, 'BareAreasConsolidated', '201 (Consolidated bare areas)'],
298 [202, 'BareAreasUnconsolidated', '202 (Unconsolidated bare areas)'],
299 [210, 'Water', '210 (Water bodies)'],
300 [220, 'SnowAndIce', '220 (Permanent snow and ice)']]
301 assert response.json() == expected_resp
304 def test_get_controlled_vocabulary_unknown_field(self, client, db):
305 response = client.get("/controlled_vocabulary/Station Landuse Type")
306 expected_status_code = 200
307 assert response.status_code == expected_status_code
308 expected_resp = "No controlled vocabulary found for 'Station Landuse Type'"
309 assert response.json() == expected_resp
312 def test_get_database_statistics(self, client, db):
313 response = client.get("/database_statistics")
314 expected_status_code = 200
315 assert response.status_code == expected_status_code
316 # since returned data records are just estimated, this means that they are treated as 0
317 expected_resp = {'data records': 0, 'stations': 3, 'time-series': 5, 'users': 1}
318 assert response.json() == expected_resp
321 def test_get_database_statistics_field(self, client, db):
322 response = client.get("/database_statistics/stations")
323 expected_status_code = 200
324 assert response.status_code == expected_status_code
325 expected_resp = 3
326 assert response.json() == expected_resp
329 def test_get_geopeas_urls(self, client, db):
330 response = client.get("/geopeas_urls")
331 expected_status_code = 200
332 assert response.status_code == expected_status_code
333 expected_resp = [
334 {
335 "service_url":"{base_url}/climatic_zone/?lat={lat}&lon={lon}",
336 "variable_name":"climatic_zone_year2016"
337 },
338 {
339 "service_url":"{base_url}/major_road/?lat={lat}&lon={lon}",
340 "variable_name":"distance_to_major_road_year2020"
341 },
342 {
343 "service_url":"{base_url}/population_density/?year=2015&lat={lat}&lon={lon}",
344 "variable_name":"mean_population_density_250m_year2015"
345 },
346 {
347 "service_url":"{base_url}/population_density/?year=2015&lat={lat}&lon={lon}",
348 "variable_name":"mean_population_density_5km_year2015"
349 },
350 {
351 "service_url":"{base_url}/population_density/?agg=max&radius=25000&year=2015&lat={lat}&lon={lon}",
352 "variable_name":"max_population_density_25km_year2015"
353 },
354 {
355 "service_url":"{base_url}/population_density/?agg=mean&radius=250&year=1990&lat={lat}&lon={lon}",
356 "variable_name":"mean_population_density_250m_year1990"
357 },
358 {
359 "service_url":"{base_url}/population_density/?agg=mean&radius=5000&year=1990&lat={lat}&lon={lon}",
360 "variable_name":"mean_population_density_5km_year1990"
361 },
362 {
363 "service_url":"{base_url}/population_density/?agg=max&radius=25000&year=1990&lat={lat}&lon={lon}",
364 "variable_name":"max_population_density_25km_year1990"
365 },
366 {
367 "service_url":"{base_url}/nox_emissions/?year=2015&lat={lat}&lon={lon}",
368 "variable_name":"mean_nox_emissions_10km_year2015"
369 },
370 {
371 "service_url":"{base_url}/nox_emissions/?year=2000&lat={lat}&lon={lon}",
372 "variable_name":"mean_nox_emissions_10km_year2000"
373 },
374 {
375 "service_url":"{base_url}/htap_region_tier1/?lat={lat}&country={country}",
376 "variable_name":"htap_region_tier1_year2010"
377 },
378 {
379 "service_url":"{base_url}/ecoregion/?description=false&lat={lat}&lon={lon}",
380 "variable_name":"dominant_ecoregion_year2017"
381 },
382 {
383 "service_url":"{base_url}/landcover/?year=2012&description=false&lat={lat}&lon={lon}",
384 "variable_name":"dominant_landcover_year2012"
385 },
386 {
387 "service_url":"{base_url}/ecoregion/?description=true&radius=25000&lat={lat}&lon={lon}",
388 "variable_name":"ecoregion_description_25km_year2017"
389 },
390 {
391 "service_url":"{base_url}/landcover/?year=2012&radius=25000&description=true&lat={lat}&lon={lon}",
392 "variable_name":"landcover_description_25km_year2012"
393 },
394 {
395 "service_url":"{base_url}/topography_srtm/?relative=false&lat={lat}&lon={lon}",
396 "variable_name":"mean_topography_srtm_alt_90m_year1994"
397 },
398 {
399 "service_url":"{base_url}/topography_srtm/?relative=false&agg=mean&radius=1000&lat={lat}&lon={lon}",
400 "variable_name":"mean_topography_srtm_alt_1km_year1994"
401 },
402 {
403 "service_url":"{base_url}/topography_srtm/?relative=true&agg=max&radius=5000&lat={lat}&lon={lon}",
404 "variable_name":"max_topography_srtm_relative_alt_5km_year1994"
405 },
406 {
407 "service_url":"{base_url}/topography_srtm/?relative=true&agg=min&radius=5000&lat={lat}&lon={lon}",
408 "variable_name":"min_topography_srtm_relative_alt_5km_year1994"
409 },
410 {
411 "service_url":"{base_url}/topography_srtm/?relative=true&agg=stddev&radius=5000&lat={lat}&lon={lon}",
412 "variable_name":"stddev_topography_srtm_relative_alt_5km_year1994"
413 },
414 {
415 "service_url":"{base_url}/stable_nightlights/?year=2013&lat={lat}&lon={lon}",
416 "variable_name":"mean_stable_nightlights_1km_year2013"
417 },
418 {
419 "service_url":"{base_url}/stable_nightlights/?agg=mean&radius=5000&year=2013&lat={lat}&lon={lon}",
420 "variable_name":"mean_stable_nightlights_5km_year2013"
421 },
422 {
423 "service_url":"{base_url}/stable_nightlights/?agg=max&radius=25000&year=2013&lat={lat}&lon={lon}",
424 "variable_name":"max_stable_nightlights_25km_year2013"
425 },
426 {
427 "service_url":"{base_url}/stable_nightlights/?agg=max&radius=25000&year=1992&lat={lat}&lon={lon}",
428 "variable_name":"max_stable_nightlights_25km_year1992"
429 }
430 ]
431 set_expected_resp = {json.dumps(item, sort_keys=True) for item in expected_resp}
432 set_response = {json.dumps(item, sort_keys=True) for item in response.json()}
433 assert set_response == set_expected_resp