11# SPDX-FileCopyrightText: 2017 Fermi Research Alliance, LLC
22# SPDX-License-Identifier: Apache-2.0
33
4+ import json
5+
6+ from unittest .mock import MagicMock , patch
7+
8+ import bill_calculator_hep .GCEBillAnalysis
49import pandas
10+ import pytest
511import structlog
612
7- from bill_calculator_hep .GCEBillAnalysis import GCEBillCalculator
13+ from google .auth .exceptions import DefaultCredentialsError , RefreshError
14+ from pandas .testing import assert_frame_equal
815
916from decisionengine_modules .GCE .sources import GCEBillingInfo
1017
1118# TODO
1219# The GCEBillingInfo module needs to be refactored so that tests
1320# 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.
1822
1923config_billing_info = {
20- "channel_name" : "Test " ,
21- "projectId" : "hepcloud-fnal " ,
24+ "channel_name" : "GCETest " ,
25+ "projectId" : "hc-de-test " ,
2226 "lastKnownBillDate" : "10/01/18 00:00" , # '%m/%d/%y %H:%M'
2327 "balanceAtDate" : 100.0 , # $
24- "accountName" : "None" ,
25- "accountNumber" : 1111 ,
26- "credentialsProfileName" : "BillingBlah" ,
2728 "applyDiscount" : True , # DLT discount does not apply to credits
28- "botoConfig" : ".boto3" ,
29- "localFileDir" : "." ,
3029}
3130
3231
@@ -35,19 +34,241 @@ def test_produces():
3534 assert bi_pub ._produces == {"GCE_Billing_Info" : pandas .DataFrame }
3635
3736
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" ,
4479 "lastKnownBillDate" : "10/01/18 00:00" ,
4580 "balanceAtDate" : 100.0 ,
4681 "applyDiscount" : True ,
4782 }
48- globalConf = {"graphite_host" : "dummy" , "graphite_context_billing" : "dummy" , "outputPath" : "." }
4983
50- calculator = GCEBillCalculator (None , globalConf , constantsDict , structlog .getLogger ())
5184
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