foreman_scc_manager 1.6.1 → 1.6.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/app/controllers/scc_accounts_controller.rb +17 -11
- data/app/lib/actions/scc_manager/subscribe_product.rb +1 -0
- data/app/lib/actions/scc_manager/sync.rb +1 -0
- data/app/lib/actions/scc_manager/sync_plan_account_repositories.rb +38 -0
- data/app/lib/actions/scc_manager/sync_repositories.rb +3 -2
- data/app/lib/scc_manager.rb +1 -0
- data/app/models/concerns/recurring_logic_extensions.rb +9 -0
- data/app/models/scc_account.rb +123 -1
- data/app/models/scc_account_sync_plan_task_group.rb +7 -0
- data/app/models/scc_product.rb +1 -0
- data/app/views/scc_account_sync_plan_task_groups/_scc_account_sync_plan_task_groups.html.erb +4 -0
- data/app/views/scc_accounts/_form.html.erb +4 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20190417202427_add_recurring_sync.foreman_scc_manager.rb +26 -0
- data/lib/foreman_scc_manager/engine.rb +4 -0
- data/lib/foreman_scc_manager/version.rb +1 -1
- data/lib/tasks/rubocop.rake +32 -0
- data/lib/tasks/test.rake +26 -0
- data/test/features/data_products_page1.json +10468 -0
- data/test/features/data_products_page2.json +11277 -0
- data/test/features/data_subscriptions.json +88 -0
- data/test/features/sync_test.rb +141 -0
- data/test/fixtures/models/scc_accounts.yml +24 -0
- data/test/fixtures/models/scc_products.yml +21 -0
- data/test/models/scc_account_test.rb +74 -0
- data/test/models/scc_product_test.rb +32 -0
- data/test/support/fixtures_support.rb +12 -0
- data/test/test_plugin_helper.rb +54 -0
- metadata +43 -5
- data/lib/tasks/foreman_scc_manager_tasks.rake +0 -45
@@ -0,0 +1,88 @@
|
|
1
|
+
[
|
2
|
+
{
|
3
|
+
"id": 1,
|
4
|
+
"regcode": "0123456789ABCD",
|
5
|
+
"name": "Expired Product number one",
|
6
|
+
"type": "evaluation",
|
7
|
+
"status": "EXPIRED",
|
8
|
+
"starts_at": "2014-11-13T00:00:00.000Z",
|
9
|
+
"expires_at": "2015-01-12T00:00:00.000Z",
|
10
|
+
"system_limit": 1,
|
11
|
+
"systems_count": 3,
|
12
|
+
"virtual_count": null,
|
13
|
+
"product_classes": [
|
14
|
+
"HPC-X86",
|
15
|
+
"7261"
|
16
|
+
],
|
17
|
+
"product_ids": [
|
18
|
+
1234,
|
19
|
+
5678
|
20
|
+
],
|
21
|
+
"skus": [],
|
22
|
+
"systems": [
|
23
|
+
{
|
24
|
+
"id": 3,
|
25
|
+
"login": "SCC_0123456789abcdef0123456789abcdef",
|
26
|
+
"password": "fedcba9876543210",
|
27
|
+
"last_seen_at": null
|
28
|
+
},
|
29
|
+
{
|
30
|
+
"id": 4,
|
31
|
+
"login": "SCC_123456789abcdef0123456789abcdef0",
|
32
|
+
"password": "edcba9876543210f",
|
33
|
+
"last_seen_at": null
|
34
|
+
},
|
35
|
+
{
|
36
|
+
"id": 5,
|
37
|
+
"login": "SCC_23456789abcdef0123456789abcdef01",
|
38
|
+
"password": "dcba9876543210fe",
|
39
|
+
"last_seen_at": null
|
40
|
+
}
|
41
|
+
]
|
42
|
+
},
|
43
|
+
{
|
44
|
+
"id": 2,
|
45
|
+
"regcode": "123456789ABCDE",
|
46
|
+
"name": "Some other Product which is ACTIVE",
|
47
|
+
"type": "full",
|
48
|
+
"status": "ACTIVE",
|
49
|
+
"starts_at": "2014-03-13T00:00:00.000Z",
|
50
|
+
"expires_at": "2020-01-31T00:00:00.000Z",
|
51
|
+
"system_limit": 1,
|
52
|
+
"systems_count": 0,
|
53
|
+
"virtual_count": null,
|
54
|
+
"product_classes": [
|
55
|
+
"VMDP"
|
56
|
+
],
|
57
|
+
"product_ids": [
|
58
|
+
1337
|
59
|
+
],
|
60
|
+
"skus": [],
|
61
|
+
"systems": []
|
62
|
+
},
|
63
|
+
{
|
64
|
+
"id": 3,
|
65
|
+
"regcode": "23456789ABCDEF",
|
66
|
+
"name": "Yet Another ACTIVE SUSE Product",
|
67
|
+
"type": "full",
|
68
|
+
"status": "ACTIVE",
|
69
|
+
"starts_at": "2014-03-13T00:00:00.000Z",
|
70
|
+
"expires_at": "2020-01-31T00:00:00.000Z",
|
71
|
+
"system_limit": 1,
|
72
|
+
"systems_count": 0,
|
73
|
+
"virtual_count": null,
|
74
|
+
"product_classes": [
|
75
|
+
"HPC-X86",
|
76
|
+
"SUSE_RT",
|
77
|
+
"7261",
|
78
|
+
"13319"
|
79
|
+
],
|
80
|
+
"product_ids": [
|
81
|
+
123,
|
82
|
+
456,
|
83
|
+
789
|
84
|
+
],
|
85
|
+
"skus": [],
|
86
|
+
"systems": []
|
87
|
+
}
|
88
|
+
]
|
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'test_plugin_helper'
|
2
|
+
|
3
|
+
class SccAccountSyncTest < ActiveSupport::TestCase
|
4
|
+
# rubocop:disable Metrics/MethodLength
|
5
|
+
def setup
|
6
|
+
# test_connection:
|
7
|
+
stub_request(:get, 'https://scc.example.com/connect/organizations/subscriptions')
|
8
|
+
.with(
|
9
|
+
headers: {
|
10
|
+
'Accept' => 'application/vnd.scc.suse.com.v4+json',
|
11
|
+
'Accept-Encoding' => 'gzip, deflate',
|
12
|
+
'Authorization' => 'Basic b25ldXNlcjpvbmVwYXNz',
|
13
|
+
'Host' => 'scc.example.com'
|
14
|
+
}
|
15
|
+
).to_return(
|
16
|
+
status: 200,
|
17
|
+
body: Zlib.gzip(File.read("#{File.dirname(__FILE__)}/data_subscriptions.json")),
|
18
|
+
headers: {
|
19
|
+
server: 'nginx',
|
20
|
+
date: 'Tue, 05 Mar 2019 15:07:38 GMT',
|
21
|
+
content_type: 'application/json; charset=utf-8',
|
22
|
+
transfer_encoding: 'chunked',
|
23
|
+
connection: 'keep-alive',
|
24
|
+
vary: 'Accept-Encoding',
|
25
|
+
x_frame_options: 'SAMEORIGIN',
|
26
|
+
x_xss_protection: '1; mode=block',
|
27
|
+
x_content_type_options: 'nosniff',
|
28
|
+
per_page: '10',
|
29
|
+
total: '3',
|
30
|
+
scc_api_version: 'v4',
|
31
|
+
etag: 'W/"0123456789abcdef0123456789abcdef"',
|
32
|
+
cache_control: 'max-age=0, private, must-revalidate',
|
33
|
+
set_cookie: [
|
34
|
+
'XSRF-TOKEN=TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBp%0Ac2NpbmcgZWxpdCwgc2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2l; path=/; secure',
|
35
|
+
'Uy#u~osh#oh3ahv.op0OII; Expires=Fri, 02-Mar-2029 15:07:20 GMT; Path=/'
|
36
|
+
],
|
37
|
+
x_request_id: '67450237-e4aa-4994-a47d-ed3ce142555b',
|
38
|
+
x_runtime: '0.144083',
|
39
|
+
strict_transport_security: 'max-age=15552000, max-age=300',
|
40
|
+
content_encoding: 'gzip'
|
41
|
+
}
|
42
|
+
)
|
43
|
+
############
|
44
|
+
# Products #
|
45
|
+
############
|
46
|
+
# products page1
|
47
|
+
stub_request(:get, 'https://scc.example.com/connect/organizations/products')
|
48
|
+
.with(
|
49
|
+
headers: {
|
50
|
+
'Accept' => 'application/vnd.scc.suse.com.v4+json',
|
51
|
+
'Accept-Encoding' => 'gzip, deflate',
|
52
|
+
'Authorization' => 'Basic b25ldXNlcjpvbmVwYXNz',
|
53
|
+
'Host' => 'scc.example.com'
|
54
|
+
}
|
55
|
+
).to_return(
|
56
|
+
status: 200,
|
57
|
+
body: Zlib.gzip(File.read("#{File.dirname(__FILE__)}/data_products_page1.json")),
|
58
|
+
headers: {
|
59
|
+
server: 'nginx',
|
60
|
+
date: 'Mon, 11 Mar 2019 15:37:00 GMT',
|
61
|
+
content_type: 'application/json; charset=utf-8',
|
62
|
+
transfer_encoding: 'chunked',
|
63
|
+
connection: 'keep-alive',
|
64
|
+
vary: 'Accept-Encoding',
|
65
|
+
x_frame_options: 'SAMEORIGIN',
|
66
|
+
x_xss_protection: '1; mode=block',
|
67
|
+
x_content_type_options: 'nosniff',
|
68
|
+
link: '<https://scc.example.com/connect/organizations/products?page=2>; rel="last", <https://scc.example.com/connect/organizations/products?page=2>; rel="next"',
|
69
|
+
per_page: 25,
|
70
|
+
total: 50,
|
71
|
+
scc_api_version: 'v4',
|
72
|
+
etag: '57fbfddfb5cc165b2581d297cad27a53',
|
73
|
+
cache_control: 'max-age=0, private, must-revalidate',
|
74
|
+
set_cookie: [
|
75
|
+
'XSRF-TOKEN=EABKsiefcpa7dMNEXRixmihKeUfIvXF4AwmNQt2wZG5Fm%2FPKvR0%2FMBDVV5lZJ3p4waUAcds2xWv42vbKg9GQhg%3D%3D; path=/; secure',
|
76
|
+
'TbBx+jfg=v1jitvAA@@UII; Expires=Thu, 08-Mar-2029 15:37:15 GMT; Path=/'
|
77
|
+
],
|
78
|
+
x_request_id: 'd2797941-1aed-499c-8e06-b4cb52515443',
|
79
|
+
x_runtime: '6.671012',
|
80
|
+
strict_transport_security: 'max-age=15552000, max-age=300',
|
81
|
+
content_encoding: 'gzip'
|
82
|
+
}
|
83
|
+
)
|
84
|
+
# products page2
|
85
|
+
stub_request(:get, 'https://scc.example.com/connect/organizations/products?page=2')
|
86
|
+
.with(
|
87
|
+
headers: {
|
88
|
+
'Accept' => 'application/vnd.scc.suse.com.v4+json',
|
89
|
+
'Accept-Encoding' => 'gzip, deflate',
|
90
|
+
'Authorization' => 'Basic b25ldXNlcjpvbmVwYXNz',
|
91
|
+
'Host' => 'scc.example.com'
|
92
|
+
}
|
93
|
+
).to_return(
|
94
|
+
status: 200,
|
95
|
+
body: Zlib.gzip(File.read("#{File.dirname(__FILE__)}/data_products_page2.json")),
|
96
|
+
headers: {
|
97
|
+
server: 'nginx',
|
98
|
+
date: 'Mon, 11 Mar 2019 15:37:19 GMT',
|
99
|
+
content_type: 'application/json; charset=utf-8',
|
100
|
+
transfer_encoding: 'chunked',
|
101
|
+
connection: 'keep-alive',
|
102
|
+
vary: 'Accept-Encoding',
|
103
|
+
x_frame_options: 'SAMEORIGIN',
|
104
|
+
x_xss_protection: '1; mode=block',
|
105
|
+
x_content_type_options: 'nosniff',
|
106
|
+
link: '<https://scc.example.com/connect/organizations/products?page=1>; rel="first", <https://scc.example.com/connect/organizations/products?page=1>; rel="prev"',
|
107
|
+
per_page: 3,
|
108
|
+
total: 2,
|
109
|
+
scc_api_version: 'v4',
|
110
|
+
etag: '3fb638e3ab553dc6c88ef9914540b4bd',
|
111
|
+
cache_control: 'max-age=0, private, must-revalidate',
|
112
|
+
set_cookie: [
|
113
|
+
'XSRF-TOKEN=z3bGc45lQxf%2FXq7qN7cwzJrK1zcw4e7uuskVCPejeN0zv3ExUcb8ev3jhGnDGJaSz3ZwV7Dk0SdLII%2FOcI2eEw%3D%3D; path=/; secure',
|
114
|
+
'TbBx+jfg=v1oytvAA@@I73; Expires=Thu, 08-Mar-2029 15:37:23 GMT; Path=/'
|
115
|
+
],
|
116
|
+
x_request_id: '17e6707a-1134-403d-a49c-7344442446c1',
|
117
|
+
x_runtime: '6.671012',
|
118
|
+
strict_transport_security: 'max-age=15552000, max-age=300',
|
119
|
+
content_encoding: 'gzip'
|
120
|
+
}
|
121
|
+
)
|
122
|
+
end
|
123
|
+
# rubocop:enable Metrics/MethodLength
|
124
|
+
|
125
|
+
test 'SCC server connection-test' do
|
126
|
+
assert scc_accounts(:one).test_connection
|
127
|
+
end
|
128
|
+
|
129
|
+
test 'SCC server sync products new' do
|
130
|
+
scc_account = scc_accounts(:one)
|
131
|
+
assert scc_account
|
132
|
+
products = ::SccManager.get_scc_data(
|
133
|
+
scc_account.base_url,
|
134
|
+
'/connect/organizations/products',
|
135
|
+
scc_account.login,
|
136
|
+
scc_account.password
|
137
|
+
)
|
138
|
+
assert_not_nil(products)
|
139
|
+
assert_equal(50, products.length)
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
---
|
2
|
+
one:
|
3
|
+
login: oneuser
|
4
|
+
password: onepass
|
5
|
+
base_url: https://scc.example.com
|
6
|
+
name: onename
|
7
|
+
interval: never
|
8
|
+
organization_id: <%= ActiveRecord::FixtureSet.identify(:empty_organization) %>
|
9
|
+
|
10
|
+
two:
|
11
|
+
login: twouser
|
12
|
+
password: twopass
|
13
|
+
base_url: https://scc.example.com
|
14
|
+
name: twoname
|
15
|
+
interval: never
|
16
|
+
organization_id: <%= ActiveRecord::FixtureSet.identify(:empty_organization) %>
|
17
|
+
|
18
|
+
account_missing_url:
|
19
|
+
login: fakeuser1
|
20
|
+
password: fakepass1
|
21
|
+
name: fake1
|
22
|
+
interval: never
|
23
|
+
organization_id: <%= ActiveRecord::FixtureSet.identify(:empty_organization) %>
|
24
|
+
...
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
2
|
+
|
3
|
+
one:
|
4
|
+
scc_account_id: test_account1
|
5
|
+
scc_id: 111
|
6
|
+
name: one
|
7
|
+
version: 1
|
8
|
+
arch: x86_128
|
9
|
+
friendly_name: number one
|
10
|
+
description: lorem ipsum dolor sit amet
|
11
|
+
product_type: base
|
12
|
+
|
13
|
+
two:
|
14
|
+
scc_account_id: test_account1
|
15
|
+
scc_id: 222
|
16
|
+
name: two
|
17
|
+
version: 2
|
18
|
+
arch: x86_128
|
19
|
+
friendly_name: number two
|
20
|
+
description: lorem ipsum dolor sit amet
|
21
|
+
product_type: extras
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'test_plugin_helper'
|
2
|
+
|
3
|
+
class SccAccountCreateTest < ActiveSupport::TestCase
|
4
|
+
def setup
|
5
|
+
@account = scc_accounts(:one)
|
6
|
+
end
|
7
|
+
|
8
|
+
test 'create' do
|
9
|
+
assert @account.save
|
10
|
+
refute_empty SccAccount.where(:id => @account.id)
|
11
|
+
end
|
12
|
+
|
13
|
+
test 'create default url' do
|
14
|
+
account = scc_accounts(:account_missing_url)
|
15
|
+
assert account.save
|
16
|
+
assert_equal account.base_url, 'https://scc.suse.com'
|
17
|
+
refute_empty SccAccount.where(:id => @account.id)
|
18
|
+
end
|
19
|
+
|
20
|
+
test 'create missing value' do
|
21
|
+
list = {
|
22
|
+
name: 'Not Working',
|
23
|
+
organization: get_organization,
|
24
|
+
# base_url has a default value set in DB
|
25
|
+
# base_url: 'https://scc.example.org',
|
26
|
+
login: 'account1',
|
27
|
+
password: 'secret'
|
28
|
+
}
|
29
|
+
|
30
|
+
# for every key in hash try to create account without it set
|
31
|
+
list.each_key do |k|
|
32
|
+
l = list.clone
|
33
|
+
l.delete(k)
|
34
|
+
assert_raises ActiveRecord::RecordInvalid do
|
35
|
+
SccAccount.new(l).save!
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
test 'create wrong interval-value' do
|
41
|
+
account = scc_accounts(:two)
|
42
|
+
account.interval = 'gazillion'
|
43
|
+
assert_not account.save
|
44
|
+
end
|
45
|
+
|
46
|
+
test 'password is saved encrypted when updated' do
|
47
|
+
assert SccAccount.encrypts? :password
|
48
|
+
@account.expects(:encryption_key).at_least_once.returns('25d224dd383e92a7e0c82b8bf7c985e815f34cf5')
|
49
|
+
@account.password = '123456'
|
50
|
+
as_admin do
|
51
|
+
assert @account.save
|
52
|
+
end
|
53
|
+
assert_equal @account.password, '123456'
|
54
|
+
refute_equal @account.password_in_db, '123456'
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class SccAccountSearchTest < ActiveSupport::TestCase
|
59
|
+
test 'default ordered by login' do
|
60
|
+
assert_equal SccAccount.all.pluck(:login), ['oneuser', 'twouser', 'fakeuser1'].sort
|
61
|
+
end
|
62
|
+
|
63
|
+
test 'search login' do
|
64
|
+
one = scc_accounts(:one)
|
65
|
+
accounts = SccAccount.search_for("login = \"#{one.login}\"")
|
66
|
+
assert_includes accounts, one
|
67
|
+
refute_includes accounts, scc_accounts(:two)
|
68
|
+
|
69
|
+
empty = SccAccount.search_for('login = "nobody"')
|
70
|
+
assert_empty empty
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# FIXME: test cascaded delete
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'test_plugin_helper'
|
2
|
+
|
3
|
+
class SccProductCreateTest < ActiveSupport::TestCase
|
4
|
+
def setup
|
5
|
+
@product = scc_products(:one)
|
6
|
+
end
|
7
|
+
|
8
|
+
test 'create' do
|
9
|
+
assert @product.save
|
10
|
+
refute_empty SccProduct.where(id: @product.id)
|
11
|
+
end
|
12
|
+
|
13
|
+
test 'uniq_name' do
|
14
|
+
assert_equal @product.uniq_name, '111 number one'
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class SccProductSearchTest < ActiveSupport::TestCase
|
19
|
+
test 'default ordered by name' do
|
20
|
+
assert_equal SccProduct.all.pluck(:name), ['one', 'two'].sort
|
21
|
+
end
|
22
|
+
|
23
|
+
test 'search name' do
|
24
|
+
one = scc_products(:one)
|
25
|
+
products = SccProduct.search_for("name = \"#{one.name}\"")
|
26
|
+
assert_includes products, one
|
27
|
+
refute_includes products, scc_products(:two)
|
28
|
+
|
29
|
+
empty = SccProduct.search_for('name = "nothing"')
|
30
|
+
assert_empty empty
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module ForemanSccManager
|
2
|
+
module FixturesSupport
|
3
|
+
FIXTURE_CLASSES = {
|
4
|
+
scc_accounts: ForemanSccManager::SccAccount,
|
5
|
+
scc_products: ForemanSccManager::SccProduct
|
6
|
+
}.freeze
|
7
|
+
|
8
|
+
def self.set_fixture_classes(test_class)
|
9
|
+
FIXTURE_CLASSES.each { |k, v| test_class.set_fixture_class(k => v) }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
data/test/test_plugin_helper.rb
CHANGED
@@ -1,2 +1,56 @@
|
|
1
1
|
# This calls the main test_helper in Foreman-core
|
2
2
|
require 'test_helper'
|
3
|
+
|
4
|
+
require 'foreman_tasks/test_helpers'
|
5
|
+
require "#{ForemanSccManager::Engine.root}/test/support/fixtures_support"
|
6
|
+
|
7
|
+
module FixtureTestCase
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
extend ActiveRecord::TestFixtures
|
12
|
+
|
13
|
+
self.use_instantiated_fixtures = false
|
14
|
+
self.pre_loaded_fixtures = true
|
15
|
+
|
16
|
+
ForemanSccManager::FixturesSupport.set_fixture_classes(self)
|
17
|
+
|
18
|
+
# Fixtures are copied into a separate path to combine with Foreman fixtures. This directory
|
19
|
+
# is kept out of version control.
|
20
|
+
self.fixture_path = "#{Rails.root}/tmp/combined_fixtures/"
|
21
|
+
FileUtils.rm_rf(self.fixture_path) if File.directory?(self.fixture_path)
|
22
|
+
Dir.mkdir(self.fixture_path)
|
23
|
+
FileUtils.cp(Dir.glob("#{ForemanSccManager::Engine.root}/test/fixtures/models/*"), self.fixture_path)
|
24
|
+
FileUtils.cp(Dir.glob("#{Rails.root}/test/fixtures/*"), self.fixture_path)
|
25
|
+
fixtures(:all)
|
26
|
+
FIXTURES = load_fixtures(ActiveRecord::Base)
|
27
|
+
|
28
|
+
Setting::Content.load_defaults
|
29
|
+
|
30
|
+
User.current = ::User.unscoped.find(FIXTURES['users']['admin']['id'])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class ActiveSupport::TestCase
|
35
|
+
include FactoryBot::Syntax::Methods
|
36
|
+
include FixtureTestCase
|
37
|
+
include ForemanTasks::TestHelpers::WithInThreadExecutor
|
38
|
+
|
39
|
+
before do
|
40
|
+
Setting::Content.load_defaults
|
41
|
+
end
|
42
|
+
|
43
|
+
def get_organization(org = nil)
|
44
|
+
saved_user = User.current
|
45
|
+
User.current = User.unscoped.find(users(:admin).id)
|
46
|
+
org = org.nil? ? :empty_organization : org
|
47
|
+
organization = Organization.find(taxonomies(org.to_sym).id)
|
48
|
+
organization.stubs(:label_not_changed).returns(true)
|
49
|
+
organization.setup_label_from_name
|
50
|
+
location = Location.where(name: 'Location 1').first
|
51
|
+
organization.locations << location
|
52
|
+
organization.save!
|
53
|
+
User.current = saved_user
|
54
|
+
organization
|
55
|
+
end
|
56
|
+
end
|