pricingset_test.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. package pricing
  2. import (
  3. "testing"
  4. "time"
  5. "github.com/opencost/opencost/core/pkg/cloud"
  6. "github.com/opencost/opencost/core/pkg/unit"
  7. )
  8. func nodePricing(instanceType string, price float64) *NodePricing {
  9. return &NodePricing{
  10. Properties: NodePricingProperties{
  11. Provider: cloud.ProviderAWS,
  12. Region: "us-east-1",
  13. InstanceType: instanceType,
  14. },
  15. Prices: Prices{
  16. ResourceNode: {Unit: unit.Hour, Price: price},
  17. },
  18. }
  19. }
  20. func pvPricing(volumeType VolumeType, price float64) *PersistentVolumePricing {
  21. return &PersistentVolumePricing{
  22. Properties: PersistentVolumePricingProperties{
  23. Provider: cloud.ProviderAWS,
  24. Region: "us-east-1",
  25. VolumeType: volumeType,
  26. },
  27. Prices: Prices{
  28. ResourceStorage: {Unit: unit.GiBHour, Price: price},
  29. },
  30. }
  31. }
  32. // TestChecksumPriceSensitivity verifies that the checksum changes when only a
  33. // price value changes, even if all properties are identical.
  34. func TestChecksumPriceSensitivity(t *testing.T) {
  35. a := &PricingSet{NodePricing: []*NodePricing{nodePricing("m5.large", 0.096)}}
  36. b := &PricingSet{NodePricing: []*NodePricing{nodePricing("m5.large", 0.192)}}
  37. csA, err := a.Checksum()
  38. if err != nil {
  39. t.Fatalf("unexpected error: %v", err)
  40. }
  41. csB, err := b.Checksum()
  42. if err != nil {
  43. t.Fatalf("unexpected error: %v", err)
  44. }
  45. if csA == csB {
  46. t.Errorf("expected differing checksums for differing prices, got %q for both", csA)
  47. }
  48. }
  49. // TestChecksumOrderStability verifies that the checksum is independent of the
  50. // ordering of pricing slices.
  51. func TestChecksumOrderStability(t *testing.T) {
  52. n1 := nodePricing("m5.large", 0.096)
  53. n2 := nodePricing("m5.xlarge", 0.192)
  54. n3 := nodePricing("m5.2xlarge", 0.384)
  55. forward := &PricingSet{NodePricing: []*NodePricing{n1, n2, n3}}
  56. reverse := &PricingSet{NodePricing: []*NodePricing{n3, n2, n1}}
  57. csForward, err := forward.Checksum()
  58. if err != nil {
  59. t.Fatalf("unexpected error: %v", err)
  60. }
  61. csReverse, err := reverse.Checksum()
  62. if err != nil {
  63. t.Fatalf("unexpected error: %v", err)
  64. }
  65. if csForward != csReverse {
  66. t.Errorf("expected checksum to be order-independent, got %q vs %q", csForward, csReverse)
  67. }
  68. }
  69. // TestChecksumNilReceiver verifies that Checksum handles a nil receiver like
  70. // IsEmpty and Currencies do, rather than panicking.
  71. func TestChecksumNilReceiver(t *testing.T) {
  72. var ps *PricingSet
  73. if _, err := ps.Checksum(); err != nil {
  74. t.Errorf("unexpected error on nil receiver: %v", err)
  75. }
  76. }
  77. // TestIsEmptyAllKinds verifies that a set holding only Cluster/Network/Service
  78. // pricing is not reported empty.
  79. func TestIsEmptyAllKinds(t *testing.T) {
  80. if !(&PricingSet{}).IsEmpty() {
  81. t.Errorf("expected empty set to report empty")
  82. }
  83. cases := map[string]*PricingSet{
  84. "cluster": {ClusterPricing: []*ClusterPricing{{Properties: ClusterPricingProperties{Provider: cloud.ProviderAWS}}}},
  85. "network": {NetworkPricing: []*NetworkPricing{{Properties: NetworkPricingProperties{Provider: cloud.ProviderAWS}}}},
  86. "node": {NodePricing: []*NodePricing{nodePricing("m5.large", 0.096)}},
  87. "volume": {PersistentVolumePricing: []*PersistentVolumePricing{pvPricing(VolumeTypeGP3, 0.0001)}},
  88. "service": {ServicePricing: []*ServicePricing{{Properties: ServicePricingProperties{Provider: cloud.ProviderAWS}}}},
  89. }
  90. for name, ps := range cases {
  91. if ps.IsEmpty() {
  92. t.Errorf("set with only %s pricing should not be empty", name)
  93. }
  94. }
  95. }
  96. // fullPricingSet returns a PricingSet that exercises every kind plus every
  97. // reference-typed field (Labels maps, Prices maps, and *time.Time pointers) so
  98. // that Clone independence can be verified end to end.
  99. func fullPricingSet() *PricingSet {
  100. start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  101. end := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
  102. node := nodePricing("m5.large", 0.096)
  103. node.Properties.Labels = map[string]string{"team": "platform"}
  104. node.Properties.Start = &start
  105. node.Properties.End = &end
  106. pv := pvPricing(VolumeTypeGP3, 0.0001)
  107. pv.Properties.Labels = map[string]string{"env": "prod"}
  108. pv.Properties.Start = &start
  109. return &PricingSet{
  110. ClusterPricing: []*ClusterPricing{{
  111. Properties: ClusterPricingProperties{Provider: cloud.ProviderAWS, Start: &start},
  112. Prices: Prices{ResourceCluster: {Unit: unit.Hour, Price: 1.0}},
  113. }},
  114. NetworkPricing: []*NetworkPricing{{
  115. Properties: NetworkPricingProperties{Provider: cloud.ProviderAWS, End: &end},
  116. Prices: Prices{ResourceInternetEgress: {Unit: unit.GiB, Price: 0.09}},
  117. }},
  118. NodePricing: []*NodePricing{node},
  119. PersistentVolumePricing: []*PersistentVolumePricing{pv},
  120. ServicePricing: []*ServicePricing{{
  121. Properties: ServicePricingProperties{Provider: cloud.ProviderAWS, Region: "us-east-1", Start: &start},
  122. Prices: Prices{ResourceService: {Unit: unit.Hour, Price: 0.025}},
  123. }},
  124. }
  125. }
  126. // TestCloneEquality verifies that a clone is equal to the original by checksum.
  127. func TestCloneEquality(t *testing.T) {
  128. orig := fullPricingSet()
  129. clone := orig.Clone()
  130. csOrig, err := orig.Checksum()
  131. if err != nil {
  132. t.Fatalf("unexpected error: %v", err)
  133. }
  134. csClone, err := clone.Checksum()
  135. if err != nil {
  136. t.Fatalf("unexpected error: %v", err)
  137. }
  138. if csOrig != csClone {
  139. t.Errorf("expected clone checksum %q to equal original %q", csClone, csOrig)
  140. }
  141. }
  142. // TestCloneIndependence verifies that mutating a clone's nested slices, maps,
  143. // and time pointers does not affect the original.
  144. func TestCloneIndependence(t *testing.T) {
  145. orig := fullPricingSet()
  146. csBefore, err := orig.Checksum()
  147. if err != nil {
  148. t.Fatalf("unexpected error: %v", err)
  149. }
  150. clone := orig.Clone()
  151. // Mutate every reference-typed field reachable from the clone.
  152. clone.NodePricing[0].Properties.Labels["team"] = "mutated"
  153. clone.NodePricing[0].Prices[ResourceNode] = Price{Unit: unit.Hour, Price: 99}
  154. *clone.NodePricing[0].Properties.Start = time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)
  155. clone.NodePricing[0].Properties.End = nil
  156. clone.PersistentVolumePricing[0].Properties.Labels["env"] = "mutated"
  157. clone.PersistentVolumePricing[0].Prices[ResourceStorage] = Price{Unit: unit.GiBHour, Price: 99}
  158. clone.ClusterPricing[0].Prices[ResourceCluster] = Price{Unit: unit.Hour, Price: 99}
  159. clone.NetworkPricing[0].Prices[ResourceInternetEgress] = Price{Unit: unit.GiB, Price: 99}
  160. clone.ServicePricing[0].Prices[ResourceService] = Price{Unit: unit.Hour, Price: 99}
  161. // Replace whole slices to confirm the slice headers are independent too.
  162. clone.NodePricing = append(clone.NodePricing, nodePricing("m5.xlarge", 0.192))
  163. csAfter, err := orig.Checksum()
  164. if err != nil {
  165. t.Fatalf("unexpected error: %v", err)
  166. }
  167. if csBefore != csAfter {
  168. t.Errorf("mutating clone changed original: checksum %q -> %q", csBefore, csAfter)
  169. }
  170. }
  171. // TestCloneNilReceiver verifies that Clone handles a nil receiver by returning
  172. // an empty, non-nil set rather than panicking.
  173. func TestCloneNilReceiver(t *testing.T) {
  174. var ps *PricingSet
  175. clone := ps.Clone()
  176. if clone == nil {
  177. t.Fatal("expected non-nil clone from nil receiver")
  178. }
  179. if !clone.IsEmpty() {
  180. t.Errorf("expected empty clone from nil receiver")
  181. }
  182. }
  183. // TestClonePreservesNilSlices verifies that Clone does not turn nil pricing
  184. // slices into empty ones, keeping serialization semantics stable.
  185. func TestClonePreservesNilSlices(t *testing.T) {
  186. clone := (&PricingSet{}).Clone()
  187. if clone.NodePricing != nil {
  188. t.Errorf("expected nil NodePricing slice, got %v", clone.NodePricing)
  189. }
  190. if clone.ClusterPricing != nil {
  191. t.Errorf("expected nil ClusterPricing slice, got %v", clone.ClusterPricing)
  192. }
  193. }
  194. // TestMockGetPricingSetAllKinds verifies that the mock's GetPricingSet exposes
  195. // the same kinds as its readers, not just node + persistent volume.
  196. func TestMockGetPricingSetAllKinds(t *testing.T) {
  197. mpm, err := NewMockPricingModule()
  198. if err != nil {
  199. t.Fatalf("unexpected error: %v", err)
  200. }
  201. ps, err := mpm.GetPricingSet(t.Context())
  202. if err != nil {
  203. t.Fatalf("unexpected error: %v", err)
  204. }
  205. if len(ps.NodePricing) != len(mpm.NodePricing) {
  206. t.Errorf("expected %d node pricing, got %d", len(mpm.NodePricing), len(ps.NodePricing))
  207. }
  208. if len(ps.PersistentVolumePricing) != len(mpm.PersistentVolumePricing) {
  209. t.Errorf("expected %d volume pricing, got %d", len(mpm.PersistentVolumePricing), len(ps.PersistentVolumePricing))
  210. }
  211. if len(ps.ClusterPricing) != len(mpm.ClusterPricing) {
  212. t.Errorf("expected %d cluster pricing, got %d", len(mpm.ClusterPricing), len(ps.ClusterPricing))
  213. }
  214. if len(ps.NetworkPricing) != len(mpm.NetworkPricing) {
  215. t.Errorf("expected %d network pricing, got %d", len(mpm.NetworkPricing), len(ps.NetworkPricing))
  216. }
  217. if len(ps.ServicePricing) != len(mpm.ServicePricing) {
  218. t.Errorf("expected %d service pricing, got %d", len(mpm.ServicePricing), len(ps.ServicePricing))
  219. }
  220. }