Repository navigation
Expand file tree
/
Copy pathFeatureFlagTest.java
More file actions
305 lines (250 loc) · 11.9 KB
/
Copy pathFeatureFlagTest.java
File metadata and controls
305 lines (250 loc) · 11.9 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
package dev.faststats;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class FeatureFlagTest {
private static FlagServer server;
@BeforeAll
public static void startServer() throws IOException {
server = new FlagServer();
System.setProperty("faststats.flags-server", server.url());
}
@AfterAll
public static void stopServer() throws IOException {
System.clearProperty("faststats.flags-server");
server.close();
}
@BeforeEach
public void resetServer() {
server.reset();
}
@Test
public void booleanFlagFetchesAndCachesValue() throws Exception {
server.enqueue(200, "{\"value\":true}");
final var service = service(Duration.ofMinutes(5));
final var flag = service.define("new_commands", false);
assertEquals("new_commands", flag.getId());
assertEquals(FeatureFlag.Type.BOOLEAN, flag.getType());
assertEquals(Boolean.class, flag.getTypeClass());
assertFalse(flag.getDefaultValue());
assertEquals(true, flag.whenReady().get(1, TimeUnit.SECONDS));
assertEquals(Optional.of(true), flag.getCached());
assertTrue(flag.isValid());
assertTrue(flag.getExpiration().isPresent());
final var request = server.takeRequest();
assertEquals("/v1/check", request.path());
assertEquals("Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", request.headers().get("authorization").getAsString());
assertEquals("new_commands", request.body().get("key").getAsString());
}
@Test
public void stringAndNumberFlagsUseDefaultValueTypes() throws Exception {
server.enqueue(200, "{\"value\":\"zstd\"}");
server.enqueue(200, "{\"value\":12.5}");
final var service = service(Duration.ofMinutes(5));
final var stringFlag = service.define("compression", "gzip");
assertEquals(FeatureFlag.Type.STRING, stringFlag.getType());
assertEquals(String.class, stringFlag.getTypeClass());
assertEquals("gzip", stringFlag.getDefaultValue());
assertEquals("zstd", stringFlag.whenReady().get(1, TimeUnit.SECONDS));
final var numberFlag = service.define("sample_rate", 1);
assertEquals(FeatureFlag.Type.NUMBER, numberFlag.getType());
assertEquals(Number.class, numberFlag.getTypeClass());
assertEquals(1, numberFlag.getDefaultValue());
assertEquals(12.5, numberFlag.whenReady().get(1, TimeUnit.SECONDS).doubleValue());
}
@Test
public void serviceAndFlagAttributesAreMergedInFetchRequest() throws Exception {
server.enqueue(200, "{\"value\":true}");
final var serviceAttributes = Attributes.empty()
.put("region", "global")
.put("players", 20)
.put("premium", false);
final var flagAttributes = Attributes.empty()
.put("region", "flag")
.put("beta", true);
final var service = service(serviceAttributes, Duration.ofMinutes(5));
final var flag = service.define("targeted", false, flagAttributes);
assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS));
final var attributes = server.takeRequest().body().getAsJsonObject("attributes");
assertEquals("flag", attributes.get("region").getAsString());
assertEquals(20, attributes.get("players").getAsInt());
assertTrue(attributes.get("beta").getAsBoolean());
assertFalse(attributes.get("premium").getAsBoolean());
}
@Test
public void whenReadyUsesValidCachedValueWithoutFetchingAgain() throws Exception {
server.enqueue(200, "{\"value\":true}");
final var service = service(Duration.ofMinutes(5));
final var flag = service.define("cached", false);
assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS));
server.takeRequest();
assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS));
assertEquals(0, server.requestCountAfterWaiting(Duration.ofMillis(150)));
}
@Test
public void whenReadyRefetchesExpiredCachedValue() throws Exception {
server.enqueue(200, "{\"value\":false}");
server.enqueue(200, "{\"value\":true}");
final var service = service(Duration.ofMillis(1));
final var flag = service.define("expired", false);
assertFalse(flag.whenReady().get(1, TimeUnit.SECONDS));
server.takeRequest();
Thread.sleep(5);
assertTrue(flag.whenReady().get(1, TimeUnit.SECONDS));
assertEquals("/v1/check", server.takeRequest().path());
assertEquals(Optional.of(true), flag.getCached());
Thread.sleep(5);
assertFalse(flag.isValid());
}
@Test
public void concurrentFetchesShareInProgressRequest() throws Exception {
final var releaseResponse = new CountDownLatch(1);
server.enqueue(200, "{\"value\":true}", releaseResponse);
final var service = service(Duration.ofMinutes(5));
final var flag = service.define("shared", false);
final CompletableFuture<Boolean> first = flag.fetch();
final CompletableFuture<Boolean> second = flag.fetch();
assertSame(first, second);
assertEquals(1, server.requestCountAfterWaiting(Duration.ofMillis(150)));
releaseResponse.countDown();
assertTrue(first.get(1, TimeUnit.SECONDS));
}
@Test
public void nonSuccessfulFetchResponseFails() {
server.enqueue(500, "{\"value\":true}");
final var service = service(Duration.ofMinutes(5));
final var flag = service.define("broken", false);
final var error = assertThrows(Exception.class, () -> flag.whenReady().get(1, TimeUnit.SECONDS));
assertInstanceOf(IllegalStateException.class, error.getCause());
}
private static SimpleFeatureFlagService service(final Duration ttl) {
return service(Attributes.empty(), ttl);
}
private static SimpleFeatureFlagService service(final Attributes attributes, final Duration ttl) {
return new SimpleFeatureFlagService(new MockContext.Factory()
.featureFlagService(FeatureFlagService.Factory::create)
.create(), attributes, ttl);
}
private static final class FlagServer implements AutoCloseable {
private final ServerSocket socket;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final LinkedBlockingQueue<Response> responses = new LinkedBlockingQueue<>();
private final LinkedBlockingQueue<Request> requests = new LinkedBlockingQueue<>();
FlagServer() throws IOException {
socket = new ServerSocket(0);
executor.execute(() -> {
while (!socket.isClosed()) {
try {
final var client = socket.accept();
executor.execute(() -> handle(client));
} catch (final IOException e) {
if (!socket.isClosed()) throw new UncheckedIOException(e);
}
}
});
}
String url() {
return "http://127.0.0.1:" + socket.getLocalPort();
}
void enqueue(final int status, final String body) {
enqueue(status, body, null);
}
void enqueue(final int status, final String body, @Nullable final CountDownLatch release) {
responses.add(new Response(status, body, release));
}
void reset() {
requests.clear();
responses.clear();
}
Request takeRequest() throws InterruptedException {
final var request = requests.poll(1, TimeUnit.SECONDS);
if (request == null) throw new AssertionError("Timed out waiting for request");
return request;
}
int requestCountAfterWaiting(final Duration duration) throws InterruptedException {
Thread.sleep(duration.toMillis());
return requests.size();
}
private void handle(final Socket client) {
try (client) {
final var reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
final var requestLine = reader.readLine();
if (requestLine == null) return;
final var path = requestLine.split(" ")[1];
final var headers = new JsonObject();
int contentLength = 0;
String line;
while ((line = reader.readLine()) != null && !line.isEmpty()) {
final var separator = line.indexOf(':');
final var name = line.substring(0, separator).toLowerCase();
final var value = line.substring(separator + 1).trim();
headers.addProperty(name, value);
if (name.equals("content-length")) contentLength = Integer.parseInt(value);
}
final var bodyChars = new char[contentLength];
var read = 0;
while (read < contentLength) {
final var count = reader.read(bodyChars, read, contentLength - read);
if (count == -1) break;
read += count;
}
final var body = new String(bodyChars, 0, read);
requests.add(new Request(path, headers, body.isEmpty() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject()));
final var response = responses.poll(1, TimeUnit.SECONDS);
if (response == null) throw new AssertionError("No response enqueued");
if (response.release() != null) response.release().await(1, TimeUnit.SECONDS);
writeResponse(client.getOutputStream(), response);
} catch (final IOException e) {
throw new UncheckedIOException(e);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
}
}
private void writeResponse(final OutputStream output, final Response response) throws IOException {
final var bytes = response.body().getBytes(StandardCharsets.UTF_8);
final var headers = "HTTP/1.1 " + response.status() + " OK\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: " + bytes.length + "\r\n" +
"Connection: close\r\n" +
"\r\n";
output.write(headers.getBytes(StandardCharsets.UTF_8));
output.write(bytes);
}
@Override
public void close() throws IOException {
socket.close();
executor.shutdownNow();
}
}
private record Request(String path, JsonObject headers, JsonObject body) {
}
private record Response(int status, String body, @Nullable CountDownLatch release) {
}
}