Repository navigation
Expand file tree
/
Copy pathservice_details_codec_test.go
More file actions
312 lines (295 loc) · 12.2 KB
/
Copy pathservice_details_codec_test.go
File metadata and controls
312 lines (295 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
package common //nolint:revive // pkg/common is the module's core domain package (Recommendation, ProviderType, ...), imported by name (common.X) throughout this repo and by consumer-mcp; renaming would break every consumer import
import (
"encoding/json"
"fmt"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMarshalServiceDetails_NilAndPointers confirms the contract that:
// - nil interface → (nil, nil)
// - non-nil value → JSON bytes containing the fields
// - typed nil ptr → (nil, nil) (json.Marshal of a nil pointer emits
// "null"; the helper folds that to a nil RawMessage so callers can
// treat absent and null identically).
func TestMarshalServiceDetails_NilAndPointers(t *testing.T) {
t.Parallel()
b, err := MarshalServiceDetails(nil)
require.NoError(t, err)
assert.Nil(t, b, "nil interface must produce nil RawMessage")
var nilCompute *ComputeDetails
b, err = MarshalServiceDetails(nilCompute)
require.NoError(t, err)
assert.Nil(t, b, "typed nil pointer must produce nil RawMessage")
for _, tc := range []struct {
name string
in ServiceDetails
commitmentType string
}{
{"value", ComputeDetails{InstanceType: "m5.large", Platform: "Windows", GCPCommitmentType: "MEMORY_OPTIMIZED_M4_6TB"}, "MEMORY_OPTIMIZED_M4_6TB"},
{"pointer", &ComputeDetails{InstanceType: "m5.large", Platform: "Windows", GCPCommitmentType: "MEMORY_OPTIMIZED_M4_6TB"}, "MEMORY_OPTIMIZED_M4_6TB"},
{"zero_value", ComputeDetails{InstanceType: "m5.large", Platform: "Windows"}, ""},
{"zero_pointer", &ComputeDetails{InstanceType: "m5.large", Platform: "Windows"}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, err := MarshalServiceDetails(tc.in)
require.NoError(t, err)
var fields map[string]any
require.NoError(t, json.Unmarshal(b, &fields))
assert.Equal(t, "Windows", fields["platform"])
out, err := DecodeServiceDetailsFor(string(ServiceCompute), b)
require.NoError(t, err)
cd, ok := out.(*ComputeDetails)
require.True(t, ok, "want *ComputeDetails, got %T", out)
assert.Equal(t, tc.commitmentType, cd.GCPCommitmentType)
if tc.commitmentType == "" {
assert.NotContains(t, fields, "gcp_commitment_type")
} else {
assert.Equal(t, tc.commitmentType, fields["gcp_commitment_type"])
}
})
}
}
// TestDecodeServiceDetailsFor_RoundTrip is the core round-trip regression
// guard for issue #453: a Windows EC2 rec must come back out as Windows,
// not silently default to Linux/UNIX.
func TestDecodeServiceDetailsFor_RoundTrip(t *testing.T) {
t.Parallel()
cases := []struct {
name string
service string
in ServiceDetails
// assert inspects the typed pointer returned by decode and verifies
// the per-service fields round-tripped.
assert func(t *testing.T, d ServiceDetails)
}{
{
name: "gcp_compute_commitment_type",
service: string(ServiceCompute),
in: &ComputeDetails{InstanceType: "c2-standard-4", GCPCommitmentType: "MEMORY_OPTIMIZED_M4_6TB"},
assert: func(t *testing.T, d ServiceDetails) {
cd, ok := d.(*ComputeDetails)
require.True(t, ok, "want *ComputeDetails, got %T", d)
assert.Equal(t, "MEMORY_OPTIMIZED_M4_6TB", cd.GCPCommitmentType)
},
},
{
name: "ec2_windows_dedicated",
service: string(ServiceEC2),
in: &ComputeDetails{InstanceType: "m5.large", Platform: "Windows", Tenancy: "dedicated", Scope: "Region", VCPU: 2, MemoryGB: 8},
assert: func(t *testing.T, d ServiceDetails) {
cd, ok := d.(*ComputeDetails)
if assert.True(t, ok, "want *ComputeDetails, got %T", d) {
assert.Equal(t, "Windows", cd.Platform)
assert.Equal(t, "dedicated", cd.Tenancy)
assert.Equal(t, "Region", cd.Scope)
assert.Equal(t, 2, cd.VCPU)
assert.InDelta(t, 8.0, cd.MemoryGB, 0.001)
}
},
},
{
name: "rds_postgres_multiaz",
service: string(ServiceRDS),
in: &DatabaseDetails{Engine: "postgres", EngineVersion: "15", AZConfig: "multi-az", InstanceClass: "db.r5.large"},
assert: func(t *testing.T, d ServiceDetails) {
dd, ok := d.(*DatabaseDetails)
if assert.True(t, ok, "want *DatabaseDetails, got %T", d) {
assert.Equal(t, "postgres", dd.Engine)
assert.Equal(t, "15", dd.EngineVersion)
assert.Equal(t, "multi-az", dd.AZConfig)
}
},
},
{
name: "elasticache_redis",
service: string(ServiceElastiCache),
in: &CacheDetails{Engine: "redis", NodeType: "cache.r5.large", Shards: 3},
assert: func(t *testing.T, d ServiceDetails) {
cd, ok := d.(*CacheDetails)
if assert.True(t, ok, "want *CacheDetails, got %T", d) {
assert.Equal(t, "redis", cd.Engine)
assert.Equal(t, 3, cd.Shards)
}
},
},
{
name: "savings_plans_compute",
service: string(ServiceSavingsPlansCompute),
in: &SavingsPlanDetails{PlanType: "Compute", HourlyCommitment: 1.5, Coverage: "ec2"},
assert: func(t *testing.T, d ServiceDetails) {
sp, ok := d.(*SavingsPlanDetails)
if assert.True(t, ok, "want *SavingsPlanDetails, got %T", d) {
assert.Equal(t, "Compute", sp.PlanType)
assert.InDelta(t, 1.5, sp.HourlyCommitment, 0.001)
}
},
},
{
name: "opensearch",
service: string(ServiceOpenSearch),
in: &SearchDetails{InstanceType: "r5.large.search", MasterNodeCount: 3, MasterNodeType: "m6g.large.search"},
assert: func(t *testing.T, d ServiceDetails) {
sd, ok := d.(*SearchDetails)
if assert.True(t, ok, "want *SearchDetails, got %T", d) {
assert.Equal(t, "r5.large.search", sd.InstanceType)
assert.Equal(t, 3, sd.MasterNodeCount)
}
},
},
{
name: "redshift",
service: string(ServiceRedshift),
in: &DataWarehouseDetails{NodeType: "ra3.xlplus", NumberOfNodes: 4, ClusterType: "multi-node"},
assert: func(t *testing.T, d ServiceDetails) {
dw, ok := d.(*DataWarehouseDetails)
if assert.True(t, ok, "want *DataWarehouseDetails, got %T", d) {
assert.Equal(t, "ra3.xlplus", dw.NodeType)
assert.Equal(t, 4, dw.NumberOfNodes)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
blob, err := MarshalServiceDetails(tc.in)
require.NoError(t, err)
require.NotEmpty(t, blob)
out, err := DecodeServiceDetailsFor(tc.service, blob)
require.NoError(t, err)
require.NotNil(t, out)
tc.assert(t, out)
})
}
}
// TestDecodeServiceDetailsFor_LegacyEmpty pins the documented fallback
// behavior: an empty payload on a service that needs typed Details
// yields a zero-valued typed pointer (so the cloud client's type-
// assertion succeeds and buildOfferingFilters can substitute defaults).
func TestDecodeServiceDetailsFor_LegacyEmpty(t *testing.T) {
t.Parallel()
t.Run("compute_missing_commitment_type", func(t *testing.T) {
t.Parallel()
out, err := DecodeServiceDetailsFor(string(ServiceCompute), json.RawMessage(`{"instance_type":"n2-standard-4"}`))
require.NoError(t, err)
cd, ok := out.(*ComputeDetails)
require.True(t, ok, "want *ComputeDetails, got %T", out)
assert.Empty(t, cd.GCPCommitmentType)
})
for _, svc := range []string{
string(ServiceEC2),
string(ServiceRDS),
string(ServiceElastiCache),
string(ServiceSavingsPlansAll),
string(ServiceSavingsPlansCompute),
string(ServiceOpenSearch),
string(ServiceRedshift),
} {
t.Run(svc+"_nil_raw", func(t *testing.T) {
t.Parallel()
out, err := DecodeServiceDetailsFor(svc, nil)
require.NoError(t, err)
assert.NotNil(t, out, "empty raw on a known service must yield a typed zero pointer")
})
t.Run(svc+"_explicit_null", func(t *testing.T) {
t.Parallel()
out, err := DecodeServiceDetailsFor(svc, json.RawMessage(`null`))
require.NoError(t, err)
assert.NotNil(t, out, "explicit JSON null must be treated like an empty payload")
})
}
}
// TestDecodeServiceDetailsFor_UnknownService confirms that an unknown
// service slug with no payload returns (nil, nil), but a payload for it is
// rejected loudly instead of being silently dropped (A09-018).
func TestDecodeServiceDetailsFor_UnknownService(t *testing.T) {
t.Parallel()
for _, raw := range []json.RawMessage{nil, json.RawMessage(`null`)} {
out, err := DecodeServiceDetailsFor("not-a-real-service", raw)
require.NoError(t, err)
assert.Nil(t, out)
}
out, err := DecodeServiceDetailsFor("not-a-real-service", json.RawMessage(`{"foo":"bar"}`))
require.Error(t, err)
assert.Nil(t, out)
assert.Contains(t, err.Error(), "not-a-real-service")
assert.ErrorIs(t, err, ErrUntypedServiceDetails)
}
// TestDecodeServiceDetailsFor_MemoryDB pins that MemoryDB recs, which the AWS
// parser stamps with CacheDetails, still decode their persisted payload.
func TestDecodeServiceDetailsFor_MemoryDB(t *testing.T) {
t.Parallel()
out, err := DecodeServiceDetailsFor(string(ServiceMemoryDB), json.RawMessage(`{"engine":"redis","node_type":"db.r6g.large"}`))
require.NoError(t, err)
assert.IsType(t, &CacheDetails{}, out)
}
// TestDecodeServiceDetailsFor_MalformedJSON ensures a malformed payload
// surfaces as an error rather than silently producing a zero-valued
// Details (which is how #453 hid for a release).
func TestDecodeServiceDetailsFor_MalformedJSON(t *testing.T) {
t.Parallel()
_, err := DecodeServiceDetailsFor(string(ServiceEC2), json.RawMessage(`{not json}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "unmarshal")
}
// TestDecodeServiceDetailsFor_LegacyDashFormSP keeps the legacy
// "savings-plans" umbrella alias decoding to *SavingsPlanDetails so a
// purchase_execution row persisted under the dash form before PR #94
// still resolves correctly when re-executed.
func TestDecodeServiceDetailsFor_LegacyDashFormSP(t *testing.T) {
t.Parallel()
in := &SavingsPlanDetails{PlanType: "Compute", HourlyCommitment: 1.0}
blob, err := MarshalServiceDetails(in)
require.NoError(t, err)
out, err := DecodeServiceDetailsFor("savings-plans", blob)
require.NoError(t, err)
sp, ok := out.(*SavingsPlanDetails)
require.True(t, ok, "got %T", out)
assert.Equal(t, "Compute", sp.PlanType)
}
// TestDecodeServiceDetailsFor_EveryEmittedPair lists every (service slug,
// Details type) a provider sets on recommendations (audit of
// providers/{aws,azure,gcp}: aws parser_services.go / parser_sp.go,
// azure database/cache/cosmosdb/compute/synapse/savingsplans clients, gcp
// computeengine) and asserts each survives marshal -> decode with the same
// type. A pair missing from the codec makes this fail.
func TestDecodeServiceDetailsFor_EveryEmittedPair(t *testing.T) {
t.Parallel()
pairs := []struct {
service string
details ServiceDetails
}{
{string(ServiceEC2), &ComputeDetails{Platform: "Linux/UNIX"}},
{string(ServiceCompute), &ComputeDetails{Platform: "Linux/UNIX"}},
{string(ServiceCompute), ComputeDetails{MemoryGB: 8}}, // azure/gcp compute, value form
{string(ServiceRDS), &DatabaseDetails{Engine: "mysql"}},
{string(ServiceRelationalDB), &DatabaseDetails{Engine: "sqlserver"}}, // azure database
{string(ServiceElastiCache), &CacheDetails{Engine: "redis"}},
{string(ServiceCache), &CacheDetails{Engine: "redis"}}, // azure cache
{string(ServiceMemoryDB), &CacheDetails{Engine: "redis"}},
{string(ServiceOpenSearch), &SearchDetails{}},
{string(ServiceSearch), &SearchDetails{}},
{string(ServiceRedshift), &DataWarehouseDetails{}},
{string(ServiceDataWarehouse), &DataWarehouseDetails{}}, // azure synapse
{string(ServiceNoSQL), NoSQLDetails{Engine: "cosmos", APIType: "sql"}}, // azure cosmosdb (value form)
{string(ServiceSavingsPlansAll), &SavingsPlanDetails{PlanType: "Compute"}},
{string(ServiceSavingsPlansCompute), &SavingsPlanDetails{PlanType: "Compute"}},
{string(ServiceSavingsPlansEC2Instance), &SavingsPlanDetails{PlanType: "EC2Instance"}},
{string(ServiceSavingsPlansSageMaker), &SavingsPlanDetails{PlanType: "SageMaker"}},
{string(ServiceSavingsPlansDatabase), &SavingsPlanDetails{PlanType: "Database"}},
}
for _, p := range pairs {
t.Run(fmt.Sprintf("%s_%T", p.service, p.details), func(t *testing.T) {
t.Parallel()
raw, err := MarshalServiceDetails(p.details)
require.NoError(t, err)
out, err := DecodeServiceDetailsFor(p.service, raw)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, reflect.Indirect(reflect.ValueOf(p.details)).Type(), reflect.Indirect(reflect.ValueOf(out)).Type())
})
}
}