1#! /usr/bin/env python3 2# 3# BitBake Toaster Implementation 4# 5# Copyright (C) 2013-2015 Intel Corporation 6# 7# SPDX-License-Identifier: GPL-2.0-only 8# 9 10"""Test cases for Toaster GUI and ReST.""" 11 12import os 13import pytest 14from django.test import TestCase 15from django.test.client import RequestFactory 16from django.urls import reverse 17from django.db.models import Q 18 19from orm.models import Project, Package 20from orm.models import Layer_Version, Recipe 21from orm.models import CustomImageRecipe 22from orm.models import CustomImagePackage 23 24from bldcontrol.models import BuildEnvironment 25import inspect 26import toastergui 27 28from toastergui.tables import SoftwareRecipesTable 29import json 30from bs4 import BeautifulSoup 31import string 32 33PROJECT_NAME = "test project" 34PROJECT_NAME2 = "test project 2" 35CLI_BUILDS_PROJECT_NAME = 'Command line builds' 36 37 38 39class ViewTests(TestCase): 40 """Tests to verify view APIs.""" 41 42 fixtures = ['toastergui-unittest-data'] 43 builldir = os.environ.get('BUILDDIR') 44 45 def setUp(self): 46 47 self.project = Project.objects.first() 48 49 self.recipe1 = Recipe.objects.get(pk=2) 50 # create a file and to recipe1 file_path 51 file_path = f"{self.builldir}/{self.recipe1.name.strip().replace(' ', '-')}.bb" 52 with open(file_path, 'w') as f: 53 f.write('foo') 54 self.recipe1.file_path = file_path 55 self.recipe1.save() 56 57 self.customr = CustomImageRecipe.objects.first() 58 self.cust_package = CustomImagePackage.objects.first() 59 self.package = Package.objects.first() 60 self.lver = Layer_Version.objects.first() 61 if BuildEnvironment.objects.count() == 0: 62 BuildEnvironment.objects.create(betype=BuildEnvironment.TYPE_LOCAL) 63 64 65 def test_get_base_call_returns_html(self): 66 """Basic test for all-projects view""" 67 response = self.client.get(reverse('all-projects'), follow=True) 68 self.assertEqual(response.status_code, 200) 69 self.assertTrue(response['Content-Type'].startswith('text/html')) 70 self.assertTemplateUsed(response, "projects-toastertable.html") 71 72 def test_get_json_call_returns_json(self): 73 """Test for all projects output in json format""" 74 url = reverse('all-projects') 75 response = self.client.get(url, {"format": "json"}, follow=True) 76 self.assertEqual(response.status_code, 200) 77 self.assertTrue(response['Content-Type'].startswith( 78 'application/json')) 79 80 data = json.loads(response.content.decode('utf-8')) 81 82 self.assertTrue("error" in data) 83 self.assertEqual(data["error"], "ok") 84 self.assertTrue("rows" in data) 85 86 name_found = False 87 for row in data["rows"]: 88 name_found = row['name'].find(self.project.name) 89 90 self.assertTrue(name_found, 91 "project name not found in projects table") 92 93 def test_typeaheads(self): 94 """Test typeahead ReST API""" 95 layers_url = reverse('xhr_layerstypeahead', args=(self.project.id,)) 96 prj_url = reverse('xhr_projectstypeahead') 97 98 urls = [layers_url, 99 prj_url, 100 reverse('xhr_recipestypeahead', args=(self.project.id,)), 101 reverse('xhr_machinestypeahead', args=(self.project.id,))] 102 103 def basic_reponse_check(response, url): 104 """Check data structure of http response.""" 105 self.assertEqual(response.status_code, 200) 106 self.assertTrue(response['Content-Type'].startswith( 107 'application/json')) 108 109 data = json.loads(response.content.decode('utf-8')) 110 111 self.assertTrue("error" in data) 112 self.assertEqual(data["error"], "ok") 113 self.assertTrue("results" in data) 114 115 # We got a result so now check the fields 116 if len(data['results']) > 0: 117 result = data['results'][0] 118 119 self.assertTrue(len(result['name']) > 0) 120 self.assertTrue("detail" in result) 121 self.assertTrue(result['id'] > 0) 122 123 # Special check for the layers typeahead's extra fields 124 if url == layers_url: 125 self.assertTrue(len(result['layerdetailurl']) > 0) 126 self.assertTrue(len(result['vcs_url']) > 0) 127 self.assertTrue(len(result['vcs_reference']) > 0) 128 # Special check for project typeahead extra fields 129 elif url == prj_url: 130 self.assertTrue(len(result['projectPageUrl']) > 0) 131 132 return True 133 134 return False 135 136 for url in urls: 137 results = False 138 139 for typeing in list(string.ascii_letters): 140 response = self.client.get(url, {'search': typeing}) 141 results = basic_reponse_check(response, url) 142 if results: 143 break 144 145 # After "typeing" the alpabet we should have result true 146 # from each of the urls 147 self.assertTrue(results) 148 149 def test_xhr_add_layer(self): 150 """Test xhr_add API""" 151 # Test for importing an already existing layer 152 api_url = reverse('xhr_layer', args=(self.project.id,)) 153 154 layer_data = {'vcs_url': "git://git.example.com/test", 155 'name': "base-layer", 156 'git_ref': "c12b9596afd236116b25ce26dbe0d793de9dc7ce", 157 'project_id': self.project.id, 158 'local_source_dir': "", 159 'add_to_project': True, 160 'dir_path': "/path/in/repository"} 161 162 layer_data_json = json.dumps(layer_data) 163 164 response = self.client.put(api_url, layer_data_json) 165 data = json.loads(response.content.decode('utf-8')) 166 self.assertEqual(response.status_code, 200) 167 self.assertEqual(data["error"], "ok") 168 169 self.assertTrue( 170 layer_data['name'] in 171 self.project.get_all_compatible_layer_versions().values_list( 172 'layer__name', 173 flat=True), 174 "Could not find imported layer in project's all layers list" 175 ) 176 177 # Empty data passed 178 response = self.client.put(api_url, "{}") 179 data = json.loads(response.content.decode('utf-8')) 180 self.assertNotEqual(data["error"], "ok") 181 182 def test_custom_ok(self): 183 """Test successful return from ReST API xhr_customrecipe""" 184 url = reverse('xhr_customrecipe') 185 params = {'name': 'custom', 'project': self.project.id, 186 'base': self.recipe1.id} 187 response = self.client.post(url, params) 188 self.assertEqual(response.status_code, 200) 189 data = json.loads(response.content.decode('utf-8')) 190 self.assertEqual(data['error'], 'ok') 191 self.assertTrue('url' in data) 192 # get recipe from the database 193 recipe = CustomImageRecipe.objects.get(project=self.project, 194 name=params['name']) 195 args = (self.project.id, recipe.id,) 196 self.assertEqual(reverse('customrecipe', args=args), data['url']) 197 198 def test_custom_incomplete_params(self): 199 """Test not passing all required parameters to xhr_customrecipe""" 200 url = reverse('xhr_customrecipe') 201 for params in [{}, {'name': 'custom'}, 202 {'name': 'custom', 'project': self.project.id}]: 203 response = self.client.post(url, params) 204 self.assertEqual(response.status_code, 200) 205 data = json.loads(response.content.decode('utf-8')) 206 self.assertNotEqual(data["error"], "ok") 207 208 def test_xhr_custom_wrong_project(self): 209 """Test passing wrong project id to xhr_customrecipe""" 210 url = reverse('xhr_customrecipe') 211 params = {'name': 'custom', 'project': 0, "base": self.recipe1.id} 212 response = self.client.post(url, params) 213 self.assertEqual(response.status_code, 200) 214 data = json.loads(response.content.decode('utf-8')) 215 self.assertNotEqual(data["error"], "ok") 216 217 def test_xhr_custom_wrong_base(self): 218 """Test passing wrong base recipe id to xhr_customrecipe""" 219 url = reverse('xhr_customrecipe') 220 params = {'name': 'custom', 'project': self.project.id, "base": 0} 221 response = self.client.post(url, params) 222 self.assertEqual(response.status_code, 200) 223 data = json.loads(response.content.decode('utf-8')) 224 self.assertNotEqual(data["error"], "ok") 225 226 def test_xhr_custom_details(self): 227 """Test getting custom recipe details""" 228 url = reverse('xhr_customrecipe_id', args=(self.customr.id,)) 229 response = self.client.get(url) 230 self.assertEqual(response.status_code, 200) 231 expected = {"error": "ok", 232 "info": {'id': self.customr.id, 233 'name': self.customr.name, 234 'base_recipe_id': self.recipe1.id, 235 'project_id': self.project.id}} 236 self.assertEqual(json.loads(response.content.decode('utf-8')), 237 expected) 238 239 def test_xhr_custom_del(self): 240 """Test deleting custom recipe""" 241 name = "to be deleted" 242 recipe = CustomImageRecipe.objects.create( 243 name=name, project=self.project, 244 base_recipe=self.recipe1, 245 file_path=f"{self.builldir}/testing", 246 layer_version=self.customr.layer_version) 247 url = reverse('xhr_customrecipe_id', args=(recipe.id,)) 248 response = self.client.delete(url) 249 self.assertEqual(response.status_code, 200) 250 251 gotoUrl = reverse('projectcustomimages', args=(self.project.pk,)) 252 253 self.assertEqual(json.loads(response.content.decode('utf-8')), 254 {"error": "ok", 255 "gotoUrl": gotoUrl}) 256 257 # try to delete not-existent recipe 258 url = reverse('xhr_customrecipe_id', args=(recipe.id,)) 259 response = self.client.delete(url) 260 self.assertEqual(response.status_code, 200) 261 self.assertNotEqual(json.loads( 262 response.content.decode('utf-8'))["error"], "ok") 263 264 def test_xhr_custom_packages(self): 265 """Test adding and deleting package to a custom recipe""" 266 # add self.package to recipe 267 response = self.client.put(reverse('xhr_customrecipe_packages', 268 args=(self.customr.id, 269 self.cust_package.id))) 270 271 self.assertEqual(response.status_code, 200) 272 self.assertEqual(json.loads(response.content.decode('utf-8')), 273 {"error": "ok"}) 274 self.assertEqual(self.customr.appends_set.first().name, 275 self.cust_package.name) 276 # delete it 277 to_delete = self.customr.appends_set.first().pk 278 del_url = reverse('xhr_customrecipe_packages', 279 args=(self.customr.id, to_delete)) 280 281 response = self.client.delete(del_url) 282 self.assertEqual(response.status_code, 200) 283 self.assertEqual(json.loads(response.content.decode('utf-8')), 284 {"error": "ok"}) 285 all_packages = self.customr.get_all_packages().values_list('pk', 286 flat=True) 287 288 self.assertFalse(to_delete in all_packages) 289 # delete invalid package to test error condition 290 del_url = reverse('xhr_customrecipe_packages', 291 args=(self.customr.id, 292 99999)) 293 294 response = self.client.delete(del_url) 295 self.assertEqual(response.status_code, 200) 296 self.assertNotEqual(json.loads( 297 response.content.decode('utf-8'))["error"], "ok") 298 299 def test_xhr_custom_packages_err(self): 300 """Test error conditions of xhr_customrecipe_packages""" 301 # test calls with wrong recipe id and wrong package id 302 for args in [(0, self.package.id), (self.customr.id, 0)]: 303 url = reverse('xhr_customrecipe_packages', args=args) 304 # test put and delete methods 305 for method in (self.client.put, self.client.delete): 306 response = method(url) 307 self.assertEqual(response.status_code, 200) 308 self.assertNotEqual(json.loads( 309 response.content.decode('utf-8')), 310 {"error": "ok"}) 311 312 def test_download_custom_recipe(self): 313 """Download the recipe file generated for the custom image""" 314 315 # Create a dummy recipe file for the custom image generation to read 316 open(f"{self.builldir}/a_recipe.bb", 'a').close() 317 response = self.client.get(reverse('customrecipedownload', 318 args=(self.project.id, 319 self.customr.id))) 320 321 self.assertEqual(response.status_code, 200) 322 323 def test_software_recipes_table(self): 324 """Test structure returned for Software RecipesTable""" 325 table = SoftwareRecipesTable() 326 request = RequestFactory().get('/foo/', {'format': 'json'}) 327 response = table.get(request, pid=self.project.id) 328 data = json.loads(response.content.decode('utf-8')) 329 330 recipes = Recipe.objects.filter(Q(is_image=False)) 331 self.assertTrue(len(recipes) > 1, 332 "Need more than one software recipe to test " 333 "SoftwareRecipesTable") 334 335 recipe1 = recipes[0] 336 recipe2 = recipes[1] 337 338 rows = data['rows'] 339 row1 = next(x for x in rows if x['name'] == recipe1.name) 340 row2 = next(x for x in rows if x['name'] == recipe2.name) 341 342 self.assertEqual(response.status_code, 200, 'should be 200 OK status') 343 344 # check other columns have been populated correctly 345 self.assertTrue(recipe1.name in row1['name']) 346 self.assertTrue(recipe1.version in row1['version']) 347 self.assertTrue(recipe1.description in 348 row1['get_description_or_summary']) 349 350 self.assertTrue(recipe1.layer_version.layer.name in 351 row1['layer_version__layer__name']) 352 353 self.assertTrue(recipe2.name in row2['name']) 354 self.assertTrue(recipe2.version in row2['version']) 355 self.assertTrue(recipe2.description in 356 row2['get_description_or_summary']) 357 358 self.assertTrue(recipe2.layer_version.layer.name in 359 row2['layer_version__layer__name']) 360 361 def test_toaster_tables(self): 362 """Test all ToasterTables instances""" 363 364 def get_data(table, options={}): 365 """Send a request and parse the json response""" 366 options['format'] = "json" 367 options['nocache'] = "true" 368 request = RequestFactory().get('/', options) 369 370 # This is the image recipe needed for a package list for 371 # PackagesTable do this here to throw a non exist exception 372 image_recipe = Recipe.objects.get(pk=4) 373 374 # Add any kwargs that are needed by any of the possible tables 375 args = {'pid': self.project.id, 376 'layerid': self.lver.pk, 377 'recipeid': self.recipe1.pk, 378 'recipe_id': image_recipe.pk, 379 'custrecipeid': self.customr.pk, 380 'build_id': 1, 381 'target_id': 1} 382 383 response = table.get(request, **args) 384 return json.loads(response.content.decode('utf-8')) 385 386 def get_text_from_td(td): 387 """If we have html in the td then extract the text portion""" 388 # just so we don't waste time parsing non html 389 if "<" not in td: 390 ret = td 391 else: 392 ret = BeautifulSoup(td, "html.parser").text 393 394 if len(ret): 395 return "0" 396 else: 397 return ret 398 399 # Get a list of classes in tables module 400 tables = inspect.getmembers(toastergui.tables, inspect.isclass) 401 tables.extend(inspect.getmembers(toastergui.buildtables, 402 inspect.isclass)) 403 404 for name, table_cls in tables: 405 # Filter out the non ToasterTables from the tables module 406 if not issubclass(table_cls, toastergui.widgets.ToasterTable) or \ 407 table_cls == toastergui.widgets.ToasterTable or \ 408 'Mixin' in name: 409 continue 410 411 # Get the table data without any options, this also does the 412 # initialisation of the table i.e. setup_columns, 413 # setup_filters and setup_queryset that we can use later 414 table = table_cls() 415 all_data = get_data(table) 416 417 self.assertTrue(len(all_data['rows']) > 1, 418 "Cannot test on a %s table with < 1 row" % name) 419 420 if table.default_orderby: 421 row_one = get_text_from_td( 422 all_data['rows'][0][table.default_orderby.strip("-")]) 423 row_two = get_text_from_td( 424 all_data['rows'][1][table.default_orderby.strip("-")]) 425 426 if '-' in table.default_orderby: 427 self.assertTrue(row_one >= row_two, 428 "Default ordering not working on %s" 429 " '%s' should be >= '%s'" % 430 (name, row_one, row_two)) 431 else: 432 self.assertTrue(row_one <= row_two, 433 "Default ordering not working on %s" 434 " '%s' should be <= '%s'" % 435 (name, row_one, row_two)) 436 437 # Test the column ordering and filtering functionality 438 for column in table.columns: 439 if column['orderable']: 440 # If a column is orderable test it in both order 441 # directions ordering on the columns field_name 442 ascending = get_data(table_cls(), 443 {"orderby": column['field_name']}) 444 445 row_one = get_text_from_td( 446 ascending['rows'][0][column['field_name']]) 447 row_two = get_text_from_td( 448 ascending['rows'][1][column['field_name']]) 449 450 self.assertTrue(row_one <= row_two, 451 "Ascending sort applied but row 0: \"%s\"" 452 " is less than row 1: \"%s\" " 453 "%s %s " % 454 (row_one, row_two, 455 column['field_name'], name)) 456 457 descending = get_data(table_cls(), 458 {"orderby": 459 '-'+column['field_name']}) 460 461 row_one = get_text_from_td( 462 descending['rows'][0][column['field_name']]) 463 row_two = get_text_from_td( 464 descending['rows'][1][column['field_name']]) 465 466 self.assertTrue(row_one >= row_two, 467 "Descending sort applied but row 0: %s" 468 "is greater than row 1: %s" 469 "field %s table %s" % 470 (row_one, 471 row_two, 472 column['field_name'], name)) 473 474 # If the two start rows are the same we haven't actually 475 # changed the order 476 self.assertNotEqual(ascending['rows'][0], 477 descending['rows'][0], 478 "An orderby %s has not changed the " 479 "order of the data in table %s" % 480 (column['field_name'], name)) 481 482 if column['filter_name']: 483 # If a filter is available for the column get the filter 484 # info. This contains what filter actions are defined. 485 filter_info = get_data(table_cls(), 486 {"cmd": "filterinfo", 487 "name": column['filter_name']}) 488 self.assertTrue(len(filter_info['filter_actions']) > 0, 489 "Filter %s was defined but no actions " 490 "added to it" % column['filter_name']) 491 492 for filter_action in filter_info['filter_actions']: 493 # filter string to pass as the option 494 # This is the name of the filter:action 495 # e.g. project_filter:not_in_project 496 filter_string = "%s:%s" % ( 497 column['filter_name'], 498 filter_action['action_name']) 499 # Now get the data with the filter applied 500 filtered_data = get_data(table_cls(), 501 {"filter": filter_string}) 502 503 # date range filter actions can't specify the 504 # number of results they return, so their count is 0 505 if filter_action['count'] is not None: 506 self.assertEqual( 507 len(filtered_data['rows']), 508 int(filter_action['count']), 509 "We added a table filter for %s but " 510 "the number of rows returned was not " 511 "what the filter info said there " 512 "would be" % name) 513 514 # Test search functionality on the table 515 something_found = False 516 for search in list(string.ascii_letters): 517 search_data = get_data(table_cls(), {'search': search}) 518 519 if len(search_data['rows']) > 0: 520 something_found = True 521 break 522 523 self.assertTrue(something_found, 524 "We went through the whole alphabet and nothing" 525 " was found for the search of table %s" % name) 526 527 # Test the limit functionality on the table 528 limited_data = get_data(table_cls(), {'limit': "1"}) 529 self.assertEqual(len(limited_data['rows']), 530 1, 531 "Limit 1 set on table %s but not 1 row returned" 532 % name) 533 534 # Test the pagination functionality on the table 535 page_one_data = get_data(table_cls(), {'limit': "1", 536 "page": "1"})['rows'][0] 537 538 page_two_data = get_data(table_cls(), {'limit': "1", 539 "page": "2"})['rows'][0] 540 541 self.assertNotEqual(page_one_data, 542 page_two_data, 543 "Changed page on table %s but first row is" 544 " the same as the previous page" % name) 545