Skip to content

Commit 46bfc52

Browse files
Refactor Existing Test Script and Add New Unit Tests for GCE Billing (#516)
* strictly enforced bill-calculator-hep version requirement (0.2.3) * adding new unit tests for GCEBillingInfo module - includes a second test using a fake credential that has an invalid key - updated tests to reflect correct PEP8-compliant method names - updated the PEP8-compliant method name in GCEBillingInfo module --------- Co-authored-by: Marco Mambelli <[email protected]>
1 parent b7accf0 commit 46bfc52

File tree

4 files changed

+246
-25
lines changed

4 files changed

+246
-25
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ authors = [
1313
]
1414
dependencies = [
1515
"authlib",
16-
"bill-calculator-hep >= 0.1.4",
16+
"bill-calculator-hep >= 0.2.3",
1717
"boto3 >= 1.17.10",
1818
"cryptography",
1919
#"decisionengine >= 2.0",

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"structlog >= 21.1.0",
3333
"requests >= 2.14.2",
3434
"urllib3 >= 1.26.2",
35-
"bill-calculator-hep >= 0.1.4",
35+
"bill-calculator-hep >= 0.2.3",
3636
"numpy >= 1.19.5, < 2.0.0; python_version >= '3.7'",
3737
"pandas >= 1.5.3, < 2.0.0; python_version >= '3.7'",
3838
"qcs-api-client >= 0.21.1; python_version >= '3.7'",

src/decisionengine_modules/GCE/sources/GCEBillingInfo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def acquire(self):
5050
try:
5151
calculator = GCEBillCalculator(None, globalConf, constantsDict, self.logger)
5252

53-
bill_summary = calculator.CalculateBill()
53+
bill_summary = calculator.calculate_bill()
5454

5555
self.logger.info("Calculated corrected bill summary for Google (using BigQuery)")
5656
self.logger.debug(bill_summary)
Lines changed: 243 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,31 @@
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
49
import pandas
10+
import pytest
511
import 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

916
from 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

1923
config_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-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQD4499rviKPf+AR\nLuxv8vAjqi4RFAMJNQ0e5D0wkp9E1GcEW2PpXxoOF6RYBAfODkxsKD1jeVO0TusO\nY/P4YOfmnVwWl8dky7gn6JT49bOhGK17gLc3GM7mUKOiNZOowxz+c8XsiUUE1W4y\ncHioxfH09kf0AEZwXBqymrZGxJGTUM2Ksay7YCh75DbHkinjnvdelBJd7/3O1OtB\nnoj5hH7QIgqr7ntWmNgKzhgjIU72P/y5wix7Xv06J2vecJyRMd16p8PCj7W3TZQg\nAKZxugrn396buf74V60lHy9g/bVG6ppttLR5pqNz+YXyN77/j8ME+5DVLvqONA9N\nwF7khtrJAgMBAAECggEAeph4EVC/IlMZMi2cXgpa2h52AYiLdEoS8+/16gqW9Cbx\ntXY00Ru8sEtZ8tbNZ2SopS/vCSQWpH6pDtYSMvq8z94cIa7SkyY7yECqvLT+LbCD\np41/8d5A77ax23ErkhnFmtq5F+mHuzlMRgEblfqm04RKbfiCuc7MgcRuW45wrJBM\nd987fIPWLqIz0QiTiA7UjEWvT+HwXl0hEzVcepEbnXMqTMfIHh2GAok/hFxq+I9+\n+J5edmVuPnukuD10QfbdCKKKoXq1hEConBSCVMzLgJmNMCu5ZhOqnM3IiEFdvOvz\nSiWEMwqjWdCXdKRj7IlJ9/9Eyo9bHBHSXU5xxTJvBwKBgQD+os8A8lqOawWJLr4q\nDCSmu9lg8BqZFkSkqiwXrCuA7ZW00imLkJkKAs4xB+WNWmuQZrWz3B5s7MbG6s74\nT1+vDC8xoc1uc5+d0pNbTs7PqO+KeRe7oHp/yNUYHnzQCRyxprw5GTyng9+POl7r\nNnVBjjItp3/ieuBZVVtj9uXPSwKBgQD6OS9FNKUBiC5ipXxSeEKxAtN4bxKZ2ujS\nQZtPQRUz3p3U2ZMITW9IfE1tqGp9kkcvcnAYGuGqVX21AeN7ghtGPvzyu+U9Jk/+\nbNJhhmUQtHXlN50t4bDlrutbhuUJ4HjL291ITjAI5nZxrLXOjFglvZgJBl9iyGwm\n/7xDhUDtuwKBgCnacNPjAedux9YojLE0lcGiFrTMQlLvShEWt3Ccp/nlEzpJYPLD\nraPrmiCM/7ogJpXxi+QoRgf5UyLW7XX69es7wXYS9kU1VAMI3ZegeHXBer3z8Wax\nlfDy/bOdLz6ygLjigwWPlFykXFaabYeTx+oiiTTf1zFOqRmF4iOoLVXJAoGAP9ta\nIeo2dfagB9K9sHo6YtwaxbBq6dLA+e9+SDKOy6bzVn+UE1lXngMC64pAav1qp0Qo\nMS6jCoo4w3nQ6RMiDMJEYVnsPbfKUF7LLdJTdnjnYXDY7v2a3HLQY5JAX03m5fed\nODej8JGIBqiR2T1dvXvuEdeLfjUxzJ4VGJIoKMMCgYA4YhlmcZSnaq3GtaAxzkKo\nioo8yqwP0EJIQIZzIros05Z4j7iMIo9Bw0rLIoOB3Kq/g6CZ6oci71VoRsp4f76a\nvE44mvG0wCYath0p7sPIK8MY0aaOBHFeq6VeRYp1M73GnYNLRzwX7bLFeBiRkfAY\nYYSyYV7J7MUxIWwYyZ0BgQ==\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-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDY3E8o1NEFcjMM\nHW/5ZfFJw29/8NEqpViNjQIx95Xx5KDtJ+nWn9+OW0uqsSqKlKGhAdAo+Q6bjx2c\nuXVsXTu7XrZUY5Kltvj94DvUa1wjNXs606r/RxWTJ58bfdC+gLLxBfGnB6CwK0YQ\nxnfpjNbkUfVVzO0MQD7UP0Hl5ZcY0Puvxd/yHuONQn/rIAieTHH1pqgW+zrH/y3c\n59IGThC9PPtugI9ea8RSnVj3PWz1bX2UkCDpy9IRh9LzJLaYYX9RUd7++dULUlat\nAaXBh1U6emUDzhrIsgApjDVtimOPbmQWmX1S60mqQikRpVYZ8u+NDD+LNw+/Eovn\nxCj2Y3z1AgMBAAECggEAWDBzoqO1IvVXjBA2lqId10T6hXmN3j1ifyH+aAqK+FVl\nGjyWjDj0xWQcJ9ync7bQ6fSeTeNGzP0M6kzDU1+w6FgyZqwdmXWI2VmEizRjwk+/\n/uLQUcL7I55Dxn7KUoZs/rZPmQDxmGLoue60Gg6z3yLzVcKiDc7cnhzhdBgDc8vd\nQorNAlqGPRnm3EqKQ6VQp6fyQmCAxrr45kspRXNLddat3AMsuqImDkqGKBmF3Q1y\nxWGe81LphUiRqvqbyUlh6cdSZ8pLBpc9m0c3qWPKs9paqBIvgUPlvOZMqec6x4S6\nChbdkkTRLnbsRr0Yg/nDeEPlkhRBhasXpxpMUBgPywKBgQDs2axNkFjbU94uXvd5\nznUhDVxPFBuxyUHtsJNqW4p/ujLNimGet5E/YthCnQeC2P3Ym7c3fiz68amM6hiA\nOnW7HYPZ+jKFnefpAtjyOOs46AkftEg07T9XjwWNPt8+8l0DYawPoJgbM5iE0L2O\nx8TU1Vs4mXc+ql9F90GzI0x3VwKBgQDqZOOqWw3hTnNT07Ixqnmd3dugV9S7eW6o\nU9OoUgJB4rYTpG+yFqNqbRT8bkx37iKBMEReppqonOqGm4wtuRR6LSLlgcIU9Iwx\nyfH12UWqVmFSHsgZFqM/cK3wGev38h1WBIOx3/djKn7BdlKVh8kWyx6uC8bmV+E6\nOoK0vJD6kwKBgHAySOnROBZlqzkiKW8c+uU2VATtzJSydrWm0J4wUPJifNBa/hVW\ndcqmAzXC9xznt5AVa3wxHBOfyKaE+ig8CSsjNyNZ3vbmr0X04FoV1m91k2TeXNod\njMTobkPThaNm4eLJMN2SQJuaHGTGERWC0l3T18t+/zrDMDCPiSLX1NAvAoGBAN1T\nVLJYdjvIMxf1bm59VYcepbK7HLHFkRq6xMJMZbtG0ryraZjUzYvB4q4VjHk2UDiC\nlhx13tXWDZH7MJtABzjyg+AI7XWSEQs2cBXACos0M4Myc6lU+eL+iA+OuoUOhmrh\nqmT8YYGu76/IBWUSqWuvcpHPpwl7871i4Ga/I3qnAoGBANNkKAcMoeAbJQK7a/Rn\nwPEJB+dPgNDIaboAsh1nZhVhN5cvdvCWuEYgOGCPQLYQF0zmTLcM+sVxOYgfy8mV\nfbNgPgsP5xmu6dw2COBKdtozw0HrWSRjACd1N4yGu75+wPCcX/gQarcjRcXXZeEa\nNtBLSfcqPULqD+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

Comments
 (0)