athenaintegration_coverage_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. package aws
  2. import (
  3. "strings"
  4. "testing"
  5. "github.com/opencost/opencost/pkg/cloud"
  6. )
  7. func TestAthenaWhereUsage(t *testing.T) {
  8. // Regression test for https://github.com/opencost/opencost/issues/4022 -
  9. // AWS Marketplace subscription/fee charges (line_item_line_item_type = 'Fee')
  10. // must be included, but only when billed through AWS Marketplace, so that
  11. // non-Marketplace 'Fee' rows (e.g. Reserved Instance upfront purchases,
  12. // already captured via 'DiscountedUsage' amortization) are not swept in and
  13. // double counted.
  14. expected := "(line_item_line_item_type = 'Usage' OR line_item_line_item_type = 'DiscountedUsage' OR line_item_line_item_type = 'SavingsPlanCoveredUsage' OR line_item_line_item_type = 'EdpDiscount' OR line_item_line_item_type = 'PrivateRateDiscount' OR (line_item_line_item_type = 'Fee' AND bill_billing_entity = 'AWS Marketplace'))"
  15. if AthenaWhereUsage != expected {
  16. t.Errorf("AthenaWhereUsage = %v, want %v", AthenaWhereUsage, expected)
  17. }
  18. if !strings.Contains(AthenaWhereUsage, "line_item_line_item_type = 'Fee' AND bill_billing_entity = 'AWS Marketplace'") {
  19. t.Errorf("AthenaWhereUsage should include AWS Marketplace 'Fee' rows scoped to bill_billing_entity, got: %v", AthenaWhereUsage)
  20. }
  21. // A bare, unscoped 'Fee' disjunct would also match non-Marketplace fees like RI
  22. // upfront purchases, so it must never appear on its own.
  23. if strings.Contains(AthenaWhereUsage, "line_item_line_item_type = 'Fee')") {
  24. t.Errorf("AthenaWhereUsage should not include an unscoped 'Fee' clause, got: %v", AthenaWhereUsage)
  25. }
  26. }
  27. func TestAthenaWhereUsageBase(t *testing.T) {
  28. // AthenaWhereUsageBase must reference only mandatory CUR columns (never
  29. // bill_billing_entity, which CUR 2.0 exports can disable) so it is always a safe
  30. // fallback when that column is absent.
  31. expected := "(line_item_line_item_type = 'Usage' OR line_item_line_item_type = 'DiscountedUsage' OR line_item_line_item_type = 'SavingsPlanCoveredUsage' OR line_item_line_item_type = 'EdpDiscount' OR line_item_line_item_type = 'PrivateRateDiscount')"
  32. if AthenaWhereUsageBase != expected {
  33. t.Errorf("AthenaWhereUsageBase = %v, want %v", AthenaWhereUsageBase, expected)
  34. }
  35. if strings.Contains(AthenaWhereUsageBase, "bill_billing_entity") || strings.Contains(AthenaWhereUsageBase, "Fee") {
  36. t.Errorf("AthenaWhereUsageBase must not reference bill_billing_entity or 'Fee', got: %v", AthenaWhereUsageBase)
  37. }
  38. }
  39. func TestAthenaIntegration_GetWhereUsage(t *testing.T) {
  40. ai := &AthenaIntegration{}
  41. // Regression test for https://github.com/opencost/opencost/pull/4028#discussion -
  42. // bill_billing_entity is a CUR 2.0-disableable column, like resource_tags,
  43. // line_item_usage_account_name, and bill_payer_account_name elsewhere in this file.
  44. // GetWhereUsage must check allColumns before referencing it, or CUR exports that
  45. // omit the column would fail every query with COLUMN_NOT_FOUND instead of merely
  46. // missing Marketplace fees.
  47. t.Run("bill_billing_entity present", func(t *testing.T) {
  48. allColumns := map[string]bool{
  49. AthenaBillingEntityColumn: true,
  50. }
  51. got := ai.GetWhereUsage(allColumns)
  52. if got != AthenaWhereUsage {
  53. t.Errorf("GetWhereUsage() = %v, want AthenaWhereUsage (%v)", got, AthenaWhereUsage)
  54. }
  55. if !strings.Contains(got, "bill_billing_entity = 'AWS Marketplace'") {
  56. t.Errorf("GetWhereUsage() should include the Marketplace fee clause when bill_billing_entity is present, got: %v", got)
  57. }
  58. })
  59. t.Run("bill_billing_entity absent", func(t *testing.T) {
  60. allColumns := map[string]bool{
  61. "line_item_line_item_type": true,
  62. }
  63. got := ai.GetWhereUsage(allColumns)
  64. if got != AthenaWhereUsageBase {
  65. t.Errorf("GetWhereUsage() = %v, want AthenaWhereUsageBase (%v)", got, AthenaWhereUsageBase)
  66. }
  67. if strings.Contains(got, "bill_billing_entity") {
  68. t.Errorf("GetWhereUsage() must not reference bill_billing_entity when it is absent from allColumns, got: %v", got)
  69. }
  70. })
  71. t.Run("empty allColumns", func(t *testing.T) {
  72. got := ai.GetWhereUsage(map[string]bool{})
  73. if got != AthenaWhereUsageBase {
  74. t.Errorf("GetWhereUsage() = %v, want AthenaWhereUsageBase (%v)", got, AthenaWhereUsageBase)
  75. }
  76. })
  77. }
  78. func TestAthenaIntegration_GetListCostColumn(t *testing.T) {
  79. ai := &AthenaIntegration{}
  80. expected := "SUM(CASE line_item_line_item_type WHEN 'EdpDiscount' THEN 0 WHEN 'PrivateRateDiscount' THEN 0 ELSE line_item_unblended_cost END) as list_cost"
  81. actual := ai.GetListCostColumn()
  82. if actual != expected {
  83. t.Errorf("GetListCostColumn() = %v, want %v", actual, expected)
  84. }
  85. }
  86. func TestAthenaIntegration_GetNetCostColumn(t *testing.T) {
  87. ai := &AthenaIntegration{}
  88. // Test case where net pricing column exists
  89. allColumnsWithNet := map[string]bool{
  90. "line_item_net_unblended_cost": true,
  91. }
  92. expectedWithNet := "SUM(COALESCE(line_item_net_unblended_cost, line_item_unblended_cost, 0)) as net_cost"
  93. actualWithNet := ai.GetNetCostColumn(allColumnsWithNet)
  94. if actualWithNet != expectedWithNet {
  95. t.Errorf("GetNetCostColumn() with net pricing = %v, want %v", actualWithNet, expectedWithNet)
  96. }
  97. // Test case where net pricing column doesn't exist
  98. allColumnsWithoutNet := map[string]bool{
  99. "line_item_unblended_cost": true,
  100. }
  101. expectedWithoutNet := "SUM(line_item_unblended_cost) as net_cost"
  102. actualWithoutNet := ai.GetNetCostColumn(allColumnsWithoutNet)
  103. if actualWithoutNet != expectedWithoutNet {
  104. t.Errorf("GetNetCostColumn() without net pricing = %v, want %v", actualWithoutNet, expectedWithoutNet)
  105. }
  106. }
  107. func TestAthenaIntegration_GetAmortizedCostColumn(t *testing.T) {
  108. ai := &AthenaIntegration{}
  109. allColumns := map[string]bool{
  110. "reservation_effective_cost": true,
  111. "savings_plan_savings_plan_effective_cost": true,
  112. "line_item_unblended_cost": true,
  113. }
  114. result := ai.GetAmortizedCostColumn(allColumns)
  115. if !strings.Contains(result, "SUM(") || !strings.Contains(result, " as amortized_cost") {
  116. t.Errorf("GetAmortizedCostColumn() should return a SUM expression with amortized_cost alias, got: %v", result)
  117. }
  118. }
  119. func TestAthenaIntegration_GetAmortizedNetCostColumn(t *testing.T) {
  120. ai := &AthenaIntegration{}
  121. // Test case where net pricing columns exist
  122. allColumnsWithNet := map[string]bool{
  123. "line_item_net_unblended_cost": true,
  124. "reservation_net_effective_cost": true,
  125. "savings_plan_net_savings_plan_effective_cost": true,
  126. "line_item_unblended_cost": true,
  127. }
  128. resultWithNet := ai.GetAmortizedNetCostColumn(allColumnsWithNet)
  129. if !strings.Contains(resultWithNet, "SUM(") || !strings.Contains(resultWithNet, " as amortized_net_cost") {
  130. t.Errorf("GetAmortizedNetCostColumn() with net pricing should return a SUM expression with amortized_net_cost alias, got: %v", resultWithNet)
  131. }
  132. // Test case where net pricing columns don't exist
  133. allColumnsWithoutNet := map[string]bool{
  134. "reservation_effective_cost": true,
  135. "savings_plan_savings_plan_effective_cost": true,
  136. "line_item_unblended_cost": true,
  137. }
  138. resultWithoutNet := ai.GetAmortizedNetCostColumn(allColumnsWithoutNet)
  139. if !strings.Contains(resultWithoutNet, "SUM(") || !strings.Contains(resultWithoutNet, " as amortized_net_cost") {
  140. t.Errorf("GetAmortizedNetCostColumn() without net pricing should return a SUM expression with amortized_net_cost alias, got: %v", resultWithoutNet)
  141. }
  142. }
  143. func TestAthenaIntegration_GetAmortizedCostCase(t *testing.T) {
  144. ai := &AthenaIntegration{}
  145. // Test case where RI and SP pricing columns exist
  146. allColumnsWithRIAndSP := map[string]bool{
  147. "reservation_effective_cost": true,
  148. "savings_plan_savings_plan_effective_cost": true,
  149. "line_item_unblended_cost": true,
  150. }
  151. resultWithRIAndSP := ai.GetAmortizedCostCase(allColumnsWithRIAndSP)
  152. if !strings.Contains(resultWithRIAndSP, "CASE line_item_line_item_type") ||
  153. !strings.Contains(resultWithRIAndSP, "DiscountedUsage") ||
  154. !strings.Contains(resultWithRIAndSP, "SavingsPlanCoveredUsage") {
  155. t.Errorf("GetAmortizedCostCase() with RI and SP should contain CASE statement with DiscountedUsage and SavingsPlanCoveredUsage, got: %v", resultWithRIAndSP)
  156. }
  157. // Test case where neither RI nor SP pricing columns exist
  158. allColumnsWithoutRIOrSP := map[string]bool{
  159. "line_item_unblended_cost": true,
  160. }
  161. resultWithoutRIOrSP := ai.GetAmortizedCostCase(allColumnsWithoutRIOrSP)
  162. expectedWithoutRIOrSP := "line_item_unblended_cost"
  163. if resultWithoutRIOrSP != expectedWithoutRIOrSP {
  164. t.Errorf("GetAmortizedCostCase() without RI or SP should return line_item_unblended_cost, got: %v, want: %v", resultWithoutRIOrSP, expectedWithoutRIOrSP)
  165. }
  166. }
  167. func TestAthenaIntegration_GetAmortizedNetCostCase(t *testing.T) {
  168. ai := &AthenaIntegration{}
  169. // Test case where net RI and SP pricing columns exist
  170. allColumnsWithNetRIAndSP := map[string]bool{
  171. "reservation_net_effective_cost": true,
  172. "savings_plan_net_savings_plan_effective_cost": true,
  173. "line_item_net_unblended_cost": true,
  174. "line_item_unblended_cost": true,
  175. }
  176. resultWithNetRIAndSP := ai.GetAmortizedNetCostCase(allColumnsWithNetRIAndSP)
  177. if !strings.Contains(resultWithNetRIAndSP, "CASE line_item_line_item_type") ||
  178. !strings.Contains(resultWithNetRIAndSP, "DiscountedUsage") ||
  179. !strings.Contains(resultWithNetRIAndSP, "SavingsPlanCoveredUsage") {
  180. t.Errorf("GetAmortizedNetCostCase() with net RI and SP should contain CASE statement with DiscountedUsage and SavingsPlanCoveredUsage, got: %v", resultWithNetRIAndSP)
  181. }
  182. // Test case where neither net RI nor net SP pricing columns exist
  183. allColumnsWithoutNetRIOrSP := map[string]bool{
  184. "line_item_net_unblended_cost": true,
  185. "line_item_unblended_cost": true,
  186. }
  187. resultWithoutNetRIOrSP := ai.GetAmortizedNetCostCase(allColumnsWithoutNetRIOrSP)
  188. expectedStr := "COALESCE(line_item_net_unblended_cost, line_item_unblended_cost, 0)"
  189. if resultWithoutNetRIOrSP != expectedStr {
  190. t.Errorf("GetAmortizedNetCostCase() without net RI or SP should return COALESCE expression, got: %v, want: %v", resultWithoutNetRIOrSP, expectedStr)
  191. }
  192. }
  193. func TestAthenaIntegration_RemoveColumnAliases(t *testing.T) {
  194. ai := &AthenaIntegration{}
  195. columns := []string{
  196. "column1 as alias1",
  197. "column2",
  198. "column3 as alias3",
  199. "column4",
  200. }
  201. ai.RemoveColumnAliases(columns)
  202. if columns[0] != "column1" {
  203. t.Errorf("RemoveColumnAliases() should remove alias from 'column1 as alias1', got: %v", columns[0])
  204. }
  205. if columns[1] != "column2" {
  206. t.Errorf("RemoveColumnAliases() should not modify 'column2', got: %v", columns[1])
  207. }
  208. if columns[2] != "column3" {
  209. t.Errorf("RemoveColumnAliases() should remove alias from 'column3 as alias3', got: %v", columns[2])
  210. }
  211. if columns[3] != "column4" {
  212. t.Errorf("RemoveColumnAliases() should not modify 'column4', got: %v", columns[3])
  213. }
  214. }
  215. func TestAthenaIntegration_ConvertLabelToAWSTag(t *testing.T) {
  216. ai := &AthenaIntegration{}
  217. // Test case where label already has prefix
  218. labelWithPrefix := "resource_tags_user_test_label"
  219. resultWithPrefix := ai.ConvertLabelToAWSTag(labelWithPrefix)
  220. if resultWithPrefix != labelWithPrefix {
  221. t.Errorf("ConvertLabelToAWSTag() should return label unchanged if it already has prefix, got: %v, want: %v", resultWithPrefix, labelWithPrefix)
  222. }
  223. // Test case where label needs prefix
  224. labelWithoutPrefix := "test.label/with:characters-here"
  225. resultWithoutPrefix := ai.ConvertLabelToAWSTag(labelWithoutPrefix)
  226. expectedWithoutPrefix := "resource_tags_user_test_label_with_characters_here"
  227. if resultWithoutPrefix != expectedWithoutPrefix {
  228. t.Errorf("ConvertLabelToAWSTag() should add prefix and replace characters, got: %v, want: %v", resultWithoutPrefix, expectedWithoutPrefix)
  229. }
  230. }
  231. func TestAthenaIntegration_GetIsKubernetesColumn(t *testing.T) {
  232. ai := &AthenaIntegration{}
  233. // Test with some tag columns present CUR 1.0
  234. allColumnsCur10 := map[string]bool{
  235. "resource_tags_user_eks_cluster_name": true,
  236. "resource_tags_user_alpha_eksctl_io_cluster_name": true,
  237. "resource_tags_user_kubernetes_io_service_name": true,
  238. "some_other_column": true,
  239. }
  240. result := ai.GetIsKubernetesColumn(allColumnsCur10)
  241. if !strings.Contains(result, "line_item_product_code = 'AmazonEKS'") {
  242. t.Errorf("GetIsKubernetesColumn() should always include EKS check, got: %v", result)
  243. }
  244. if !strings.Contains(result, "resource_tags_user_eks_cluster_name <> ''") {
  245. t.Errorf("GetIsKubernetesColumn() should include checks for tag columns, got: %v", result)
  246. }
  247. if !strings.Contains(result, " as is_kubernetes") {
  248. t.Errorf("GetIsKubernetesColumn() should alias result as is_kubernetes, got: %v", result)
  249. }
  250. // Test with some tag columns present CUR 2.0
  251. allColumnsCur20 := map[string]bool{
  252. "resource_tags": true,
  253. "some_other_column": true,
  254. }
  255. result = ai.GetIsKubernetesColumn(allColumnsCur20)
  256. if !strings.Contains(result, "line_item_product_code = 'AmazonEKS'") {
  257. t.Errorf("GetIsKubernetesColumn() should always include EKS check, got: %v", result)
  258. }
  259. if !strings.Contains(result, "COALESCE(resource_tags['user_eks_cluster_name'], '') <> ''") {
  260. t.Errorf("GetIsKubernetesColumn() should include checks for tag columns, got: %v", result)
  261. }
  262. if !strings.Contains(result, " as is_kubernetes") {
  263. t.Errorf("GetIsKubernetesColumn() should alias result as is_kubernetes, got: %v", result)
  264. }
  265. }
  266. func TestAthenaQuerier_GetStatus(t *testing.T) {
  267. aq := &AthenaQuerier{}
  268. // Test initial status
  269. status := aq.GetStatus()
  270. if status.String() != cloud.InitialStatus.String() {
  271. t.Errorf("GetStatus() should return InitialStatus for uninitialized querier, got: %v", status)
  272. }
  273. // Test setting a specific status
  274. aq.ConnectionStatus = cloud.SuccessfulConnection
  275. status = aq.GetStatus()
  276. if status != cloud.SuccessfulConnection {
  277. t.Errorf("GetStatus() should return set status, got: %v", status)
  278. }
  279. }
  280. func TestAthenaQuerier_Equals(t *testing.T) {
  281. aq1 := &AthenaQuerier{
  282. AthenaConfiguration: AthenaConfiguration{
  283. Bucket: "bucket1",
  284. Region: "region1",
  285. Database: "database1",
  286. Table: "table1",
  287. Account: "account1",
  288. Authorizer: &AccessKey{
  289. ID: "id1",
  290. Secret: "secret1",
  291. },
  292. },
  293. }
  294. aq2 := &AthenaQuerier{
  295. AthenaConfiguration: AthenaConfiguration{
  296. Bucket: "bucket1",
  297. Region: "region1",
  298. Database: "database1",
  299. Table: "table1",
  300. Account: "account1",
  301. Authorizer: &AccessKey{
  302. ID: "id1",
  303. Secret: "secret1",
  304. },
  305. },
  306. }
  307. aq3 := &AthenaQuerier{
  308. AthenaConfiguration: AthenaConfiguration{
  309. Bucket: "bucket2", // Different bucket
  310. Region: "region1",
  311. Database: "database1",
  312. Table: "table1",
  313. Account: "account1",
  314. Authorizer: &AccessKey{
  315. ID: "id1",
  316. Secret: "secret1",
  317. },
  318. },
  319. }
  320. // Test equality
  321. if !aq1.Equals(aq2) {
  322. t.Errorf("Equals() should return true for identical configurations")
  323. }
  324. // Test inequality
  325. if aq1.Equals(aq3) {
  326. t.Errorf("Equals() should return false for different configurations")
  327. }
  328. // Test comparison with non-AthenaQuerier
  329. accessKey := &AccessKey{
  330. ID: "id1",
  331. Secret: "secret1",
  332. }
  333. if aq1.Equals(accessKey) {
  334. t.Errorf("Equals() should return false when comparing with different type")
  335. }
  336. }