1
1
# SPDX-FileCopyrightText: 2017 Fermi Research Alliance, LLC
2
2
# SPDX-License-Identifier: Apache-2.0
3
3
4
+ import json
5
+
6
+ from unittest .mock import MagicMock , patch
7
+
8
+ import bill_calculator_hep .GCEBillAnalysis
4
9
import pandas
10
+ import pytest
5
11
import structlog
6
12
7
- from bill_calculator_hep .GCEBillAnalysis import GCEBillCalculator
13
+ from google .auth .exceptions import DefaultCredentialsError , RefreshError
14
+ from pandas .testing import assert_frame_equal
8
15
9
16
from decisionengine_modules .GCE .sources import GCEBillingInfo
10
17
11
18
# TODO
12
19
# The GCEBillingInfo module needs to be refactored so that tests
13
20
# can be written. Then tests can be written to test smaller bits
14
- # of code. There is also an issue that the env has to have
15
- # BOTO_CONFIG set, this has to be done outside of the code and
16
- # can't be set in the test. Depending on how this testing is done
17
- # you may be able to mock around this.
21
+ # of code.
18
22
19
23
config_billing_info = {
20
- "channel_name" : "Test " ,
21
- "projectId" : "hepcloud-fnal " ,
24
+ "channel_name" : "GCETest " ,
25
+ "projectId" : "hc-de-test " ,
22
26
"lastKnownBillDate" : "10/01/18 00:00" , # '%m/%d/%y %H:%M'
23
27
"balanceAtDate" : 100.0 , # $
24
- "accountName" : "None" ,
25
- "accountNumber" : 1111 ,
26
- "credentialsProfileName" : "BillingBlah" ,
27
28
"applyDiscount" : True , # DLT discount does not apply to credits
28
- "botoConfig" : ".boto3" ,
29
- "localFileDir" : "." ,
30
29
}
31
30
32
31
@@ -35,19 +34,241 @@ def test_produces():
35
34
assert bi_pub ._produces == {"GCE_Billing_Info" : pandas .DataFrame }
36
35
37
36
38
- def test_unable_to_download_filelist ():
39
- constantsDict = {
40
- "projectId" : "hepcloud-fnal" ,
41
- "credentialsProfileName" : "BillingBlah" ,
42
- "accountNumber" : 1111 ,
43
- "bucketBillingName" : "billing-hepcloud-fnal" ,
37
+ @pytest .fixture
38
+ def example_expired_service_account_credential ():
39
+ return json .dumps (
40
+ {
41
+ "type" : "service_account" ,
42
+ "project_id" : "hc-de-test" ,
43
+ "private_key_id" : "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" ,
44
+ "private_key" : "-----BEGIN PRIVATE KEY-----\n MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQD4499rviKPf+AR\n Luxv8vAjqi4RFAMJNQ0e5D0wkp9E1GcEW2PpXxoOF6RYBAfODkxsKD1jeVO0TusO\n Y/P4YOfmnVwWl8dky7gn6JT49bOhGK17gLc3GM7mUKOiNZOowxz+c8XsiUUE1W4y\n cHioxfH09kf0AEZwXBqymrZGxJGTUM2Ksay7YCh75DbHkinjnvdelBJd7/3O1OtB\n noj5hH7QIgqr7ntWmNgKzhgjIU72P/y5wix7Xv06J2vecJyRMd16p8PCj7W3TZQg\n AKZxugrn396buf74V60lHy9g/bVG6ppttLR5pqNz+YXyN77/j8ME+5DVLvqONA9N\n wF7khtrJAgMBAAECggEAeph4EVC/IlMZMi2cXgpa2h52AYiLdEoS8+/16gqW9Cbx\n tXY00Ru8sEtZ8tbNZ2SopS/vCSQWpH6pDtYSMvq8z94cIa7SkyY7yECqvLT+LbCD\n p41/8d5A77ax23ErkhnFmtq5F+mHuzlMRgEblfqm04RKbfiCuc7MgcRuW45wrJBM\n d987fIPWLqIz0QiTiA7UjEWvT+HwXl0hEzVcepEbnXMqTMfIHh2GAok/hFxq+I9+\n +J5edmVuPnukuD10QfbdCKKKoXq1hEConBSCVMzLgJmNMCu5ZhOqnM3IiEFdvOvz\n SiWEMwqjWdCXdKRj7IlJ9/9Eyo9bHBHSXU5xxTJvBwKBgQD+os8A8lqOawWJLr4q\n DCSmu9lg8BqZFkSkqiwXrCuA7ZW00imLkJkKAs4xB+WNWmuQZrWz3B5s7MbG6s74\n T1+vDC8xoc1uc5+d0pNbTs7PqO+KeRe7oHp/yNUYHnzQCRyxprw5GTyng9+POl7r\n NnVBjjItp3/ieuBZVVtj9uXPSwKBgQD6OS9FNKUBiC5ipXxSeEKxAtN4bxKZ2ujS\n QZtPQRUz3p3U2ZMITW9IfE1tqGp9kkcvcnAYGuGqVX21AeN7ghtGPvzyu+U9Jk/+\n bNJhhmUQtHXlN50t4bDlrutbhuUJ4HjL291ITjAI5nZxrLXOjFglvZgJBl9iyGwm\n /7xDhUDtuwKBgCnacNPjAedux9YojLE0lcGiFrTMQlLvShEWt3Ccp/nlEzpJYPLD\n raPrmiCM/7ogJpXxi+QoRgf5UyLW7XX69es7wXYS9kU1VAMI3ZegeHXBer3z8Wax\n lfDy/bOdLz6ygLjigwWPlFykXFaabYeTx+oiiTTf1zFOqRmF4iOoLVXJAoGAP9ta\n Ieo2dfagB9K9sHo6YtwaxbBq6dLA+e9+SDKOy6bzVn+UE1lXngMC64pAav1qp0Qo\n MS6jCoo4w3nQ6RMiDMJEYVnsPbfKUF7LLdJTdnjnYXDY7v2a3HLQY5JAX03m5fed\n ODej8JGIBqiR2T1dvXvuEdeLfjUxzJ4VGJIoKMMCgYA4YhlmcZSnaq3GtaAxzkKo\n ioo8yqwP0EJIQIZzIros05Z4j7iMIo9Bw0rLIoOB3Kq/g6CZ6oci71VoRsp4f76a\n vE44mvG0wCYath0p7sPIK8MY0aaOBHFeq6VeRYp1M73GnYNLRzwX7bLFeBiRkfAY\n YYSyYV7J7MUxIWwYyZ0BgQ==\n -----END PRIVATE KEY-----\n " ,
45
+ "client_email" :
"[email protected] " ,
46
+ "client_id" : "123456789012345678901" ,
47
+ "auth_uri" : "https://accounts.google.com/o/oauth2/auth" ,
48
+ "token_uri" : "https://oauth2.googleapis.com/token" ,
49
+ "auth_provider_x509_cert_url" : "https://www.googleapis.com/oauth2/v1/certs" ,
50
+ "client_x509_cert_url" : "https://www.googleapis.com/robot/v1/metadata/x509/gcloudbillerathepcloud-fnal.iam.gserviceaccount.com" ,
51
+ "universe_domain" : "googleapis.com" ,
52
+ }
53
+ )
54
+
55
+
56
+ @pytest .fixture
57
+ def example_invalid_pk_service_account_credential ():
58
+ return json .dumps (
59
+ {
60
+ "type" : "service_account" ,
61
+ "project_id" : "hc-de-test" ,
62
+ "private_key_id" : "a0b1c2d3e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t9" ,
63
+ "private_key" : "-----BEGIN PRIVATE KEY-----\n MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDY3E8o1NEFcjMM\n HW/5ZfFJw29/8NEqpViNjQIx95Xx5KDtJ+nWn9+OW0uqsSqKlKGhAdAo+Q6bjx2c\n uXVsXTu7XrZUY5Kltvj94DvUa1wjNXs606r/RxWTJ58bfdC+gLLxBfGnB6CwK0YQ\n xnfpjNbkUfVVzO0MQD7UP0Hl5ZcY0Puvxd/yHuONQn/rIAieTHH1pqgW+zrH/y3c\n 59IGThC9PPtugI9ea8RSnVj3PWz1bX2UkCDpy9IRh9LzJLaYYX9RUd7++dULUlat\n AaXBh1U6emUDzhrIsgApjDVtimOPbmQWmX1S60mqQikRpVYZ8u+NDD+LNw+/Eovn\n xCj2Y3z1AgMBAAECggEAWDBzoqO1IvVXjBA2lqId10T6hXmN3j1ifyH+aAqK+FVl\n GjyWjDj0xWQcJ9ync7bQ6fSeTeNGzP0M6kzDU1+w6FgyZqwdmXWI2VmEizRjwk+/\n /uLQUcL7I55Dxn7KUoZs/rZPmQDxmGLoue60Gg6z3yLzVcKiDc7cnhzhdBgDc8vd\n QorNAlqGPRnm3EqKQ6VQp6fyQmCAxrr45kspRXNLddat3AMsuqImDkqGKBmF3Q1y\n xWGe81LphUiRqvqbyUlh6cdSZ8pLBpc9m0c3qWPKs9paqBIvgUPlvOZMqec6x4S6\n ChbdkkTRLnbsRr0Yg/nDeEPlkhRBhasXpxpMUBgPywKBgQDs2axNkFjbU94uXvd5\n znUhDVxPFBuxyUHtsJNqW4p/ujLNimGet5E/YthCnQeC2P3Ym7c3fiz68amM6hiA\n OnW7HYPZ+jKFnefpAtjyOOs46AkftEg07T9XjwWNPt8+8l0DYawPoJgbM5iE0L2O\n x8TU1Vs4mXc+ql9F90GzI0x3VwKBgQDqZOOqWw3hTnNT07Ixqnmd3dugV9S7eW6o\n U9OoUgJB4rYTpG+yFqNqbRT8bkx37iKBMEReppqonOqGm4wtuRR6LSLlgcIU9Iwx\n yfH12UWqVmFSHsgZFqM/cK3wGev38h1WBIOx3/djKn7BdlKVh8kWyx6uC8bmV+E6\n OoK0vJD6kwKBgHAySOnROBZlqzkiKW8c+uU2VATtzJSydrWm0J4wUPJifNBa/hVW\n dcqmAzXC9xznt5AVa3wxHBOfyKaE+ig8CSsjNyNZ3vbmr0X04FoV1m91k2TeXNod\n jMTobkPThaNm4eLJMN2SQJuaHGTGERWC0l3T18t+/zrDMDCPiSLX1NAvAoGBAN1T\n VLJYdjvIMxf1bm59VYcepbK7HLHFkRq6xMJMZbtG0ryraZjUzYvB4q4VjHk2UDiC\n lhx13tXWDZH7MJtABzjyg+AI7XWSEQs2cBXACos0M4Myc6lU+eL+iA+OuoUOhmrh\n qmT8YYGu76/IBWUSqWuvcpHPpwl7871i4Ga/I3qnAoGBANNkKAcMoeAbJQK7a/Rn\n wPEJB+dPgNDIaboAsh1nZhVhN5cvdvCWuEYgOGCPQLYQF0zmTLcM+sVxOYgfy8mV\n fbNgPgsP5xmu6dw2COBKdtozw0HrWSRjACd1N4yGu75+wPCcX/gQarcjRcXXZeEa\n NtBLSfcqPULqD+h7br9lEJnv\n -----END PRIVATE KEY-----\n " ,
64
+ "client_email" :
"[email protected] " ,
65
+ "client_id" : "123456789012345678901" ,
66
+ "auth_uri" : "https://accounts.google.com/o/oauth2/auth" ,
67
+ "token_uri" : "https://oauth2.googleapis.com/token" ,
68
+ "auth_provider_x509_cert_url" : "https://www.googleapis.com/oauth2/v1/certs" ,
69
+ "client_x509_cert_url" : "https://www.googleapis.com/robot/v1/metadata/x509/gbillerathc-de-test.iam.gserviceaccount.com" ,
70
+ "universe_domain" : "googleapis.com" ,
71
+ }
72
+ )
73
+
74
+
75
+ @pytest .fixture
76
+ def example_constants ():
77
+ return {
78
+ "projectId" : "hc-de-test" ,
44
79
"lastKnownBillDate" : "10/01/18 00:00" ,
45
80
"balanceAtDate" : 100.0 ,
46
81
"applyDiscount" : True ,
47
82
}
48
- globalConf = {"graphite_host" : "dummy" , "graphite_context_billing" : "dummy" , "outputPath" : "." }
49
83
50
- calculator = GCEBillCalculator (None , globalConf , constantsDict , structlog .getLogger ())
51
84
52
- file_list = calculator ._downloadBillFiles ()
53
- assert file_list == []
85
+ @pytest .fixture
86
+ def example_global_config ():
87
+ return {"graphite_host" : "dummy" , "graphite_context_billing" : "dummy" , "outputPath" : "." }
88
+
89
+
90
+ # the following fixture, though not used, has been included to serve as an example for the structure of the cloud billing costs query result from BigQuery
91
+ @pytest .fixture
92
+ def example_cost_dataframe ():
93
+ return pandas .DataFrame (
94
+ {
95
+ "Sku" : {0 : "sku1" , 1 : "sku2" , 2 : "sku3" , 3 : "sku4" , 4 : "sku5" },
96
+ "Service" : {0 : "service1" , 1 : "service1" , 2 : "service2" , 3 : "service2" , 4 : "service3" },
97
+ "rawCost" : {0 : 1.344769 , 1 : 35.973946 , 2 : 1.5829 , 3 : 3.000000 , 4 : 328.35625 },
98
+ "rawCredits" : {0 : 2.0000 , 1 : 0.0000 , 2 : - 1.0000 , 3 : 0.0000 , 4 : 2.0000 },
99
+ }
100
+ )
101
+
102
+
103
+ @pytest .fixture
104
+ def expected_cost_subtotals ():
105
+ return {
106
+ "service1.sku1" : {"rawCost" : 11.344769 , "Credits" : - 2.0000 , "Cost" : 9.344769 },
107
+ "service1.sku2" : {"rawCost" : 35.973946 , "Credits" : 0.0000 , "Cost" : 35.973946 },
108
+ "service2.sku3" : {"rawCost" : 1.5829 , "Credits" : - 1.0000 , "Cost" : 0.5829 },
109
+ "service2.sku4" : {"rawCost" : 3.000000 , "Credits" : 0.0000 , "Cost" : 3.000000 },
110
+ "service3.sku5" : {"rawCost" : 328.3562 , "Credits" : - 6.0000 , "Cost" : 322.3562 },
111
+ "AdjustedSupport" : 0.0 ,
112
+ "Total" : 371.257815 ,
113
+ }
114
+
115
+
116
+ @pytest .fixture
117
+ def expected_adjustments_subtotals ():
118
+ return {"service1.sku2" : 0.0 , "service2.sku3" : - 0.000001 , "service2.sku4" : - 0.000002 , "Total" : - 0.000003 }
119
+
120
+
121
+ @pytest .fixture
122
+ def expected_bill_summary ():
123
+ return pandas .DataFrame (
124
+ {
125
+ "service1.sku1" : {0 : {"rawCost" : 11.344769 , "Credits" : - 2.0000 , "Cost" : 9.344769 }},
126
+ "service1.sku2" : {0 : {"rawCost" : 35.973946 , "Credits" : 0.0000 , "Cost" : 35.973946 , "Adjustments" : 0.0 }},
127
+ "service2.sku3" : {0 : {"rawCost" : 1.5829 , "Credits" : - 1.0000 , "Cost" : 0.5829 , "Adjustments" : - 0.000001 }},
128
+ "service2.sku4" : {0 : {"rawCost" : 3.000000 , "Credits" : 0.0000 , "Cost" : 3.000000 , "Adjustments" : - 0.000002 }},
129
+ "service3.sku5" : {0 : {"rawCost" : 328.3562 , "Credits" : - 6.0000 , "Cost" : 322.3562 }},
130
+ "AdjustedSupport" : {0 : 0.0 },
131
+ "Total" : {0 : 371.257815 },
132
+ "AdjustedTotal" : {0 : 371.257815 },
133
+ "Balance" : {0 : - 271.257815 },
134
+ }
135
+ )
136
+
137
+
138
+ # the following unit test, when passed, verifies that the underlying dependency (bill-calculator-hep) for GCEBillingInfo module uses the version that involves the use of BigQuery
139
+ def test_gcebilling_dep_version (example_constants , example_global_config ):
140
+ calculator = bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator (
141
+ None , example_global_config , example_constants , structlog .getLogger ()
142
+ )
143
+
144
+ with pytest .raises (AttributeError ) as e_msg :
145
+ _ = calculator ._downloadBillFiles ()
146
+ assert str (e_msg .value ) == "'GCEBillCalculator' object has no attribute '_downloadBillFiles'"
147
+
148
+
149
+ # the following unit tests specifically cater to the parts of GCEBilling that actually rely on BigQuery
150
+ def test_unable_to_auth_to_bqclient (
151
+ tmp_path ,
152
+ example_expired_service_account_credential ,
153
+ example_invalid_pk_service_account_credential ,
154
+ example_constants ,
155
+ example_global_config ,
156
+ monkeypatch ,
157
+ ):
158
+ d = tmp_path
159
+ # test 1: testing bigquery client object instantiation
160
+ fake_cred = d / "fake_gce_cred1.json"
161
+ fake_cred .write_text (example_invalid_pk_service_account_credential , encoding = "utf-8" )
162
+
163
+ with monkeypatch .context () as m :
164
+ m .setenv ("GOOGLE_APPLICATION_CREDENTIALS" , str (fake_cred ))
165
+
166
+ calculator = bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator (
167
+ None , example_global_config , example_constants , structlog .getLogger ()
168
+ )
169
+
170
+ with pytest .raises (DefaultCredentialsError ) as e_msg :
171
+ _ = calculator .calculate_bill ()
172
+ # since DefaultCredentialsError leads to ValueError (as part of exception chaining); `__cause__` attribute holds the chained exception
173
+ err = e_msg .value
174
+ assert err .__cause__ is not None
175
+ assert isinstance (err .__cause__ , ValueError )
176
+ assert err .__cause__ .args [0 ] == "Invalid private key"
177
+
178
+ # test 2: testing valid credential file
179
+ fake_cred = d / "fake_gce_cred2.json"
180
+ fake_cred .write_text (example_expired_service_account_credential , encoding = "utf-8" )
181
+
182
+ with monkeypatch .context () as m :
183
+ m .setenv ("GOOGLE_APPLICATION_CREDENTIALS" , str (fake_cred ))
184
+
185
+ calculator = bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator (
186
+ None , example_global_config , example_constants , structlog .getLogger ()
187
+ )
188
+
189
+ with pytest .raises (RefreshError ) as e_msg :
190
+ _ = calculator .calculate_bill ()
191
+ assert e_msg .value .args [1 ]["error" ] == "invalid_grant"
192
+ assert e_msg .value .args [1 ]["error_description" ] == "Invalid grant: account not found"
193
+
194
+
195
+ def test_cost_subtotals (example_constants , example_global_config , expected_cost_subtotals , monkeypatch ):
196
+ def mock_cost_query_data (self , bqc , tst_query , cost_query = True ):
197
+ mock_data = {
198
+ "service1" : {
199
+ "sku1" : {"rawCost" : 11.344769 , "rawCredits" : - 2.0000 },
200
+ "sku2" : {"rawCost" : 35.973946 , "rawCredits" : 0.0000 },
201
+ },
202
+ "service2" : {
203
+ "sku3" : {"rawCost" : 1.5829 , "rawCredits" : - 1.0000 },
204
+ "sku4" : {"rawCost" : 3.000000 , "rawCredits" : 0.0000 },
205
+ },
206
+ "service3" : {"sku5" : {"rawCost" : 328.3562 , "rawCredits" : - 6.0000 }},
207
+ }
208
+ return mock_data , "rawCost"
209
+
210
+ monkeypatch .setattr (
211
+ bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator , "query_cloud_billing_data" , mock_cost_query_data
212
+ )
213
+
214
+ tst_calculator = bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator (
215
+ None , example_global_config , example_constants , structlog .getLogger ()
216
+ )
217
+
218
+ mock_bqclient = MagicMock ()
219
+ monkeypatch .setattr ("google.cloud.bigquery.client.Client" , mock_bqclient )
220
+ dummy_query = "SELECT * FROM TABLE"
221
+ cost_subtotals = tst_calculator .calculate_sub_totals (mock_bqclient , dummy_query , cost_query = True )
222
+ assert cost_subtotals == expected_cost_subtotals
223
+
224
+
225
+ def test_adjustments_subtotals (example_constants , example_global_config , expected_adjustments_subtotals , monkeypatch ):
226
+ def mock_adj_query_data (self , bqc , tst_query ):
227
+ mock_data = {
228
+ "service1" : {"sku2" : {"rawAdjustments" : 0.0 , "rawCredits" : 0.0 }},
229
+ "service2" : {
230
+ "sku3" : {"rawAdjustments" : - 0.000001 , "rawCredits" : 0.0 },
231
+ "sku4" : {"rawAdjustments" : - 0.000002 , "rawCredits" : 0.0 },
232
+ },
233
+ }
234
+ return mock_data , "rawAdjustments"
235
+
236
+ monkeypatch .setattr (
237
+ bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator , "query_cloud_billing_data" , mock_adj_query_data
238
+ )
239
+
240
+ tst_calculator = bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator (
241
+ None , example_global_config , example_constants , structlog .getLogger ()
242
+ )
243
+
244
+ mock_bqclient = MagicMock ()
245
+ monkeypatch .setattr ("google.cloud.bigquery.client.Client" , mock_bqclient )
246
+ dummy_query = "SELECT * FROM TABLE"
247
+ adjustments_subtotals = tst_calculator .calculate_sub_totals (mock_bqclient , dummy_query )
248
+ assert adjustments_subtotals == expected_adjustments_subtotals
249
+
250
+
251
+ def test_bill_calculation (
252
+ example_constants ,
253
+ example_global_config ,
254
+ expected_cost_subtotals ,
255
+ expected_adjustments_subtotals ,
256
+ expected_bill_summary ,
257
+ monkeypatch ,
258
+ ):
259
+ tst_calculator = bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator (
260
+ None , example_global_config , example_constants , structlog .getLogger ()
261
+ )
262
+ # mocking a BigQuery Client object...
263
+ mock_bqclient = MagicMock ()
264
+ monkeypatch .setattr (bill_calculator_hep .GCEBillAnalysis .bigquery , "Client" , mock_bqclient )
265
+
266
+ # monkeypatching the two invocations of calculateSubTotals to directly return the results of the mocked version of the queryCloudBillingData()
267
+ # the same method is called twice in the actual execution flow and returns different results depending on whether the cost_query flag is set. the mocked behavior is achieved using side effect as shown below.
268
+ with patch .object (
269
+ bill_calculator_hep .GCEBillAnalysis .GCEBillCalculator ,
270
+ "calculate_sub_totals" ,
271
+ side_effect = [expected_cost_subtotals , expected_adjustments_subtotals ],
272
+ ) as _ :
273
+ tst_bill_summary = tst_calculator .calculate_bill ()
274
+ assert_frame_equal (tst_bill_summary , expected_bill_summary )
0 commit comments