Skip to content

Commit 943f34e

Browse files
committed
add tests
1 parent 35992ec commit 943f34e

File tree

1 file changed

+310
-0
lines changed

1 file changed

+310
-0
lines changed
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
package datadog.remoteconfig.state
2+
3+
import datadog.remoteconfig.PollingRateHinter
4+
import datadog.remoteconfig.Product
5+
import datadog.remoteconfig.ReportableException
6+
import datadog.remoteconfig.tuf.RemoteConfigRequest
7+
import datadog.remoteconfig.tuf.RemoteConfigResponse
8+
import spock.lang.Specification
9+
10+
class ProductStateSpecification extends Specification {
11+
12+
PollingRateHinter hinter = Mock()
13+
14+
void 'test apply for non-ASM_DD product applies changes before removes'() {
15+
given: 'a ProductState for ASM_DATA'
16+
def productState = new ProductState(Product.ASM_DATA)
17+
def listener = new OrderRecordingListener()
18+
productState.addProductListener(listener)
19+
20+
and: 'first apply with config1 to cache it'
21+
def response1 = buildResponse([
22+
'org/ASM_DATA/config1/foo': [version: 1, length: 8, hash: 'oldhash1']
23+
])
24+
def key1 = ParsedConfigKey.parse('org/ASM_DATA/config1/foo')
25+
productState.apply(response1, [key1], hinter)
26+
listener.operations.clear() // Clear for the actual test
27+
28+
and: 'a new response with config1 (changed hash) and config2 (new)'
29+
def response2 = buildResponse([
30+
'org/ASM_DATA/config1/foo': [version: 2, length: 8, hash: 'newhash1'],
31+
'org/ASM_DATA/config2/foo': [version: 1, length: 8, hash: 'hash2']
32+
])
33+
def key2 = ParsedConfigKey.parse('org/ASM_DATA/config2/foo')
34+
35+
when: 'apply is called'
36+
def changed = productState.apply(response2, [key1, key2], hinter)
37+
38+
then: 'changes are detected'
39+
changed
40+
41+
and: 'operations happen in order: apply config1, apply config2, commit (no removes)'
42+
listener.operations == [
43+
'accept:org/ASM_DATA/config1/foo',
44+
'accept:org/ASM_DATA/config2/foo',
45+
'commit'
46+
]
47+
}
48+
49+
void 'test apply for ASM_DD product applies changes after removes'() {
50+
given: 'a ProductState for ASM_DD'
51+
def productState = new ProductState(Product.ASM_DD)
52+
def listener = new OrderRecordingListener()
53+
productState.addProductListener(listener)
54+
55+
and: 'first apply with config1 and config2 to cache them'
56+
def response1 = buildResponse([
57+
'org/ASM_DD/config1/foo': [version: 1, length: 8, hash: 'oldhash1'],
58+
'org/ASM_DD/config2/foo': [version: 1, length: 8, hash: 'hash2']
59+
])
60+
def key1 = ParsedConfigKey.parse('org/ASM_DD/config1/foo')
61+
def key2 = ParsedConfigKey.parse('org/ASM_DD/config2/foo')
62+
productState.apply(response1, [key1, key2], hinter)
63+
listener.operations.clear() // Clear for the actual test
64+
65+
and: 'a new response with only config1 (changed hash) - config2 will be removed'
66+
def response2 = buildResponse([
67+
'org/ASM_DD/config1/foo': [version: 2, length: 8, hash: 'newhash1']
68+
])
69+
70+
when: 'apply is called'
71+
def changed = productState.apply(response2, [key1], hinter)
72+
73+
then: 'changes are detected'
74+
changed
75+
76+
and: 'operations happen in order: remove config2 FIRST, then apply config1, then commit'
77+
listener.operations == [
78+
'remove:org/ASM_DD/config2/foo',
79+
'accept:org/ASM_DD/config1/foo',
80+
'commit'
81+
]
82+
}
83+
84+
void 'test ASM_DD with multiple new configs removes before applies all'() {
85+
given: 'a ProductState for ASM_DD'
86+
def productState = new ProductState(Product.ASM_DD)
87+
def listener = new OrderRecordingListener()
88+
productState.addProductListener(listener)
89+
90+
and: 'first apply with old configs'
91+
def response1 = buildResponse([
92+
'org/ASM_DD/old1/foo': [version: 1, length: 8, hash: 'hash_old1'],
93+
'org/ASM_DD/old2/foo': [version: 1, length: 8, hash: 'hash_old2']
94+
])
95+
def oldKey1 = ParsedConfigKey.parse('org/ASM_DD/old1/foo')
96+
def oldKey2 = ParsedConfigKey.parse('org/ASM_DD/old2/foo')
97+
productState.apply(response1, [oldKey1, oldKey2], hinter)
98+
listener.operations.clear() // Clear for the actual test
99+
100+
and: 'a response with completely new configs'
101+
def response2 = buildResponse([
102+
'org/ASM_DD/new1/foo': [version: 1, length: 8, hash: 'hash_new1'],
103+
'org/ASM_DD/new2/foo': [version: 1, length: 8, hash: 'hash_new2']
104+
])
105+
def newKey1 = ParsedConfigKey.parse('org/ASM_DD/new1/foo')
106+
def newKey2 = ParsedConfigKey.parse('org/ASM_DD/new2/foo')
107+
108+
when: 'apply is called'
109+
def changed = productState.apply(response2, [newKey1, newKey2], hinter)
110+
111+
then: 'changes are detected'
112+
changed
113+
114+
and: 'all removes happen before all applies'
115+
listener.operations.size() == 5 // 2 removes + 2 accepts + 1 commit
116+
listener.operations.findAll { it.startsWith('remove:') }.size() == 2
117+
listener.operations.findAll { it.startsWith('accept:') }.size() == 2
118+
119+
and: 'removes come before accepts'
120+
def firstRemoveIdx = listener.operations.findIndexOf { it.startsWith('remove:') }
121+
def lastRemoveIdx = listener.operations.findLastIndexOf { it.startsWith('remove:') }
122+
def firstAcceptIdx = listener.operations.findIndexOf { it.startsWith('accept:') }
123+
lastRemoveIdx < firstAcceptIdx
124+
}
125+
126+
void 'test no changes detected when config hashes match'() {
127+
given: 'a ProductState'
128+
def productState = new ProductState(Product.ASM_DATA)
129+
def listener = new OrderRecordingListener()
130+
productState.addProductListener(listener)
131+
132+
and: 'first apply with a config'
133+
def response = buildResponse([
134+
'org/ASM_DATA/config1/foo': [version: 1, length: 8, hash: 'hash1']
135+
])
136+
def key1 = ParsedConfigKey.parse('org/ASM_DATA/config1/foo')
137+
productState.apply(response, [key1], hinter)
138+
listener.operations.clear() // Clear for the actual test
139+
140+
when: 'apply is called again with the same hash'
141+
def changed = productState.apply(response, [key1], hinter)
142+
143+
then: 'no changes are detected'
144+
!changed
145+
146+
and: 'no listener operations occurred'
147+
listener.operations.isEmpty()
148+
}
149+
150+
void 'test error handling during apply'() {
151+
given: 'a ProductState'
152+
def productState = new ProductState(Product.ASM_DATA)
153+
def listener = Mock(ProductListener)
154+
productState.addProductListener(listener)
155+
156+
and: 'a response with a config'
157+
def response = buildResponse([
158+
'org/ASM_DATA/config1/foo': [version: 1, length: 8, hash: 'hash1']
159+
])
160+
161+
and: 'listener throws an exception'
162+
listener.accept(_, _, _) >> { throw new RuntimeException('Listener error') }
163+
164+
def key1 = ParsedConfigKey.parse('org/ASM_DATA/config1/foo')
165+
166+
when: 'apply is called'
167+
def changed = productState.apply(response, [key1], hinter)
168+
169+
then: 'changes are still detected'
170+
changed
171+
172+
and: 'commit is still called despite the error'
173+
1 * listener.commit(hinter)
174+
}
175+
176+
void 'test reportable exception is recorded'() {
177+
given: 'a ProductState'
178+
def productState = new ProductState(Product.ASM_DATA)
179+
def listener = Mock(ProductListener)
180+
productState.addProductListener(listener)
181+
182+
and: 'a response with a config'
183+
def response = buildResponse([
184+
'org/ASM_DATA/config1/foo': [version: 1, length: 8, hash: 'hash1']
185+
])
186+
187+
and: 'listener throws a ReportableException'
188+
def exception = new ReportableException('Test error')
189+
listener.accept(_, _, _) >> { throw exception }
190+
191+
def key1 = ParsedConfigKey.parse('org/ASM_DATA/config1/foo')
192+
193+
when: 'apply is called'
194+
productState.apply(response, [key1], hinter)
195+
196+
then: 'error is recorded'
197+
productState.hasError()
198+
productState.getErrors().contains(exception)
199+
}
200+
201+
void 'test configListeners are called in addition to productListeners'() {
202+
given: 'a ProductState'
203+
def productState = new ProductState(Product.ASM_DATA)
204+
def productListener = new OrderRecordingListener()
205+
def configListener = new OrderRecordingListener()
206+
productState.addProductListener(productListener)
207+
productState.addProductListener('config1', configListener)
208+
209+
and: 'a response with two configs'
210+
def response = buildResponse([
211+
'org/ASM_DATA/config1/foo': [version: 1, length: 8, hash: 'hash1'],
212+
'org/ASM_DATA/config2/foo': [version: 1, length: 8, hash: 'hash2']
213+
])
214+
215+
def key1 = ParsedConfigKey.parse('org/ASM_DATA/config1/foo')
216+
def key2 = ParsedConfigKey.parse('org/ASM_DATA/config2/foo')
217+
218+
when: 'apply is called'
219+
productState.apply(response, [key1, key2], hinter)
220+
221+
then: 'productListener received both configs'
222+
productListener.operations.findAll { it.startsWith('accept:') }.size() == 2
223+
224+
and: 'configListener only received config1'
225+
configListener.operations == [
226+
'accept:org/ASM_DATA/config1/foo',
227+
'commit'
228+
]
229+
}
230+
231+
void 'test remove operations cleanup cached data'() {
232+
given: 'a ProductState'
233+
def productState = new ProductState(Product.ASM_DATA)
234+
def listener = Mock(ProductListener)
235+
productState.addProductListener(listener)
236+
237+
and: 'first apply with a config to cache it'
238+
def response1 = buildResponse([
239+
'org/ASM_DATA/config1/foo': [version: 1, length: 8, hash: 'hash1']
240+
])
241+
def key1 = ParsedConfigKey.parse('org/ASM_DATA/config1/foo')
242+
productState.apply(response1, [key1], hinter)
243+
244+
and: 'an empty response (config should be removed)'
245+
def response2 = buildResponse([:])
246+
247+
when: 'apply is called'
248+
def changed = productState.apply(response2, [], hinter)
249+
250+
then: 'changes are detected'
251+
changed
252+
253+
and: 'listener remove was called'
254+
1 * listener.remove(key1, hinter)
255+
256+
and: 'cached data is cleaned up'
257+
productState.getCachedTargetFiles().isEmpty()
258+
productState.getConfigStates().isEmpty()
259+
}
260+
261+
// Helper methods
262+
263+
RemoteConfigResponse buildResponse(Map<String, Map> targets) {
264+
def response = Mock(RemoteConfigResponse)
265+
266+
for (def entry : targets.entrySet()) {
267+
def path = entry.key
268+
def targetData = entry.value
269+
270+
def target = new RemoteConfigResponse.Targets.ConfigTarget()
271+
def hashString = targetData.hash as String
272+
target.hashes = ['sha256': hashString]
273+
target.length = targetData.length as long
274+
275+
def custom = new RemoteConfigResponse.Targets.ConfigTarget.ConfigTargetCustom()
276+
custom.version = targetData.version as long
277+
target.custom = custom
278+
279+
response.getTarget(path) >> target
280+
response.getFileContents(path) >> "content_${targetData.hash}".bytes
281+
}
282+
283+
// Handle empty targets case
284+
if (targets.isEmpty()) {
285+
response.getTarget(_) >> null
286+
}
287+
288+
return response
289+
}
290+
291+
// Test helper class to record operation order
292+
static class OrderRecordingListener implements ProductListener {
293+
List<String> operations = []
294+
295+
@Override
296+
void accept(datadog.remoteconfig.state.ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) {
297+
operations << "accept:${configKey.toString()}"
298+
}
299+
300+
@Override
301+
void remove(datadog.remoteconfig.state.ConfigKey configKey, PollingRateHinter pollingRateHinter) {
302+
operations << "remove:${configKey.toString()}"
303+
}
304+
305+
@Override
306+
void commit(PollingRateHinter pollingRateHinter) {
307+
operations << 'commit'
308+
}
309+
}
310+
}

0 commit comments

Comments
 (0)