Skip to content

Commit d617db2

Browse files
adds AsyncSemaphore IT
1 parent 3c0bd79 commit d617db2

10 files changed

Lines changed: 694 additions & 6 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.marketdata.sdk.internal.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.marketdata.sdk.MarketDataClient;
6+
import com.marketdata.sdk.markets.MarketStatus;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.concurrent.CompletableFuture;
10+
import java.util.concurrent.TimeUnit;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.Timeout;
13+
14+
/**
15+
* Concurrency integration test against the live Market Data API. Verifies that the {@link
16+
* AsyncSemaphore} + {@link HttpTransport} pipeline correctly handles fan-out beyond the pool size:
17+
* the requests over the limit must traverse the semaphore's slow path (queue the waiter, complete
18+
* it later via {@code release}) without deadlocking or losing a permit.
19+
*
20+
* <p>Costs {@code CONCURRENCY_LIMIT + 5 = 55} requests against the live {@code /markets/status/}
21+
* endpoint per run. With a typical RTT of ~100 ms and pool size 50, the test wall time is well
22+
* under a second.
23+
*
24+
* <p>Gated by {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} like the rest of this source set.
25+
*/
26+
class AsyncSemaphoreIT {
27+
28+
/**
29+
* If a permit ever leaked or the slow-path queue stopped being drained, {@code allOf.join()}
30+
* would block forever. The 30 s timeout fails the test fast instead of leaving CI hung.
31+
*/
32+
@Test
33+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
34+
void concurrentFanOutBeyondPoolLimitCompletesWithoutDeadlock() {
35+
try (var client = new MarketDataClient(null, null, null, false)) {
36+
int n = HttpTransport.CONCURRENCY_LIMIT + 5;
37+
List<CompletableFuture<MarketStatus>> futures = new ArrayList<>(n);
38+
39+
// Fire all N requests as fast as the loop runs. With pool=50, the first 50 take the
40+
// fast path (already-completed acquire future) and dispatch immediately; requests
41+
// 51..55 take the slow path and enqueue waiters that complete only when one of the
42+
// first 50 releases.
43+
for (int i = 0; i < n; i++) {
44+
futures.add(client.markets().statusAsync());
45+
}
46+
47+
// allOf.join() throws on any underlying failure; we let it propagate so a 429 / network
48+
// hiccup surfaces as a real test failure rather than silently masking the issue.
49+
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
50+
51+
// Every response must be a valid MarketStatus. Empty results would suggest a hidden
52+
// failure (auth issue, rate limit) that wasn't observable from allOf alone.
53+
for (CompletableFuture<MarketStatus> f : futures) {
54+
MarketStatus status = f.join();
55+
assertThat(status.days()).isNotEmpty();
56+
assertThat(status.days().get(0).date()).isNotNull();
57+
}
58+
}
59+
}
60+
}

src/test/java/com/marketdata/sdk/MarketDataClientTest.java

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
import static org.assertj.core.api.Assertions.assertThat;
44

55
import com.marketdata.sdk.internal.Configuration;
6+
import com.marketdata.sdk.internal.EnvVars;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.logging.Handler;
10+
import java.util.logging.Level;
11+
import java.util.logging.LogRecord;
12+
import java.util.logging.Logger;
613
import org.junit.jupiter.api.Test;
714

815
class MarketDataClientTest {
@@ -18,17 +25,68 @@ void buildsWithExplicitToken() {
1825

1926
@Test
2027
void demoModeWhenNoTokenAvailable() {
21-
// No apiKey passed to the constructor. Demo mode iff the env/dotenv
22-
// cascade also yields nothing — true on any CI environment that
23-
// doesn't export MARKETDATA_TOKEN. This assertion is conditional
24-
// so the test stays valid in both cases.
28+
// Demo mode iff the full cascade (env var → .env → null) yields nothing. Deriving the
29+
// expectation from the same Configuration helper the constructor uses keeps the test
30+
// valid both on CI (no token anywhere → demoMode) and locally (.env-supplied token →
31+
// not demoMode); a plain `System.getenv` check would miss the .env source and break
32+
// locally.
2533
try (var client = new MarketDataClient()) {
26-
String envToken = System.getenv("MARKETDATA_TOKEN");
27-
boolean expectDemo = envToken == null || envToken.isBlank();
34+
boolean expectDemo = Configuration.loadFromProcess().resolve(null, EnvVars.TOKEN) == null;
2835
assertThat(client.isDemoMode()).isEqualTo(expectDemo);
2936
}
3037
}
3138

39+
@Test
40+
void fineLevelLoggingEmitsRedactedToken() {
41+
// The constructor logs the redacted token at FINE only. With the default logger
42+
// configuration (INFO), `LOG.isLoggable(FINE)` returns false and the line is dead from
43+
// JaCoCo's perspective. This test installs a capturing handler at FINE and asserts the
44+
// redacted token shows up — the unredacted token must not.
45+
Logger logger = Logger.getLogger(MarketDataClient.class.getName());
46+
Level previousLevel = logger.getLevel();
47+
boolean previousUseParent = logger.getUseParentHandlers();
48+
CapturingHandler capture = new CapturingHandler();
49+
logger.addHandler(capture);
50+
logger.setLevel(Level.FINE);
51+
logger.setUseParentHandlers(false);
52+
53+
try (var client = new MarketDataClient("supersecret-token-VALUE-YKT0", null, null, false)) {
54+
assertThat(client.isDemoMode()).isFalse();
55+
} finally {
56+
logger.removeHandler(capture);
57+
logger.setLevel(previousLevel);
58+
logger.setUseParentHandlers(previousUseParent);
59+
}
60+
61+
assertThat(capture.records)
62+
.anySatisfy(
63+
r -> {
64+
assertThat(r.getLevel()).isEqualTo(Level.FINE);
65+
assertThat(r.getMessage()).contains("Token");
66+
});
67+
// Whatever was logged at FINE, the raw token must never appear in any record.
68+
for (LogRecord r : capture.records) {
69+
assertThat(r.getMessage() == null ? "" : r.getMessage())
70+
.doesNotContain("supersecret-token-VALUE-YKT0");
71+
}
72+
}
73+
74+
/** Minimal {@link Handler} that buffers everything in memory for assertions. */
75+
private static final class CapturingHandler extends Handler {
76+
final List<LogRecord> records = new ArrayList<>();
77+
78+
@Override
79+
public void publish(LogRecord record) {
80+
records.add(record);
81+
}
82+
83+
@Override
84+
public void flush() {}
85+
86+
@Override
87+
public void close() {}
88+
}
89+
3290
@Test
3391
void noArgConstructorAppliesProductionDefaults() {
3492
// The no-arg constructor must be equivalent to `new MarketDataClient(null, null, null,

src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ void everySubtypeExposesBothConstructors() {
9898
}
9999
}
100100

101+
@Test
102+
void supportInfoFormatsNullContextAsNotApplicable() {
103+
// When the exception is built from ErrorContext.empty() (e.g. client-side validation
104+
// errors that fire before any HTTP request), getSupportInfo() must render each null
105+
// field as "(n/a)" instead of literal "null". Covers the null-branches of the three
106+
// ternaries in MarketDataException.getSupportInfo.
107+
var error = new BadRequestError("symbol must not be blank", ErrorContext.empty());
108+
109+
String supportInfo = error.getSupportInfo();
110+
111+
assertThat(supportInfo)
112+
.contains("BadRequestError")
113+
.contains("symbol must not be blank")
114+
.contains("Status code: (n/a)")
115+
.contains("Request ID: (n/a)")
116+
.contains("Request URL: (n/a)")
117+
.doesNotContain("null");
118+
}
119+
101120
@Test
102121
void supportInfoNeverContainsSensitiveData() {
103122
// The exception itself never receives the token; we just

src/test/java/com/marketdata/sdk/internal/ConfigurationTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,29 @@ void missingDotEnvReturnsEmpty(@TempDir Path tmp) {
130130
assertThat(Configuration.readDotEnvFile(tmp.resolve(".env"))).isEmpty();
131131
}
132132

133+
@Test
134+
void mismatchedQuotesArePreservedVerbatim(@TempDir Path tmp) throws IOException {
135+
// stripQuotes only strips when the first AND last characters match (both " or both ').
136+
// Lines with mixed or unbalanced quotes must keep the value as-is. Covers the right-hand
137+
// false branches of the `||` in (first == '"' && last == '"') || (first == '\'' && last ==
138+
// '\'').
139+
Path dotenv = tmp.resolve(".env");
140+
Files.writeString(
141+
dotenv,
142+
"""
143+
UNCLOSED_DOUBLE="abc
144+
UNCLOSED_SINGLE='abc
145+
MIXED_QUOTES="abc'
146+
""");
147+
148+
Map<String, String> parsed = Configuration.readDotEnvFile(dotenv);
149+
150+
assertThat(parsed)
151+
.containsEntry("UNCLOSED_DOUBLE", "\"abc")
152+
.containsEntry("UNCLOSED_SINGLE", "'abc")
153+
.containsEntry("MIXED_QUOTES", "\"abc'");
154+
}
155+
133156
@Test
134157
void dotEnvParsingIntegratesWithCascade(@TempDir Path tmp) throws IOException {
135158
Path dotenv = tmp.resolve(".env");
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.marketdata.sdk.internal.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.marketdata.sdk.exception.AuthenticationError;
6+
import com.marketdata.sdk.exception.BadRequestError;
7+
import com.marketdata.sdk.exception.MarketDataException;
8+
import com.marketdata.sdk.exception.RateLimitError;
9+
import com.marketdata.sdk.exception.ServerError;
10+
import org.junit.jupiter.api.Test;
11+
12+
class HttpStatusMapperTest {
13+
14+
private static final String URL = "https://api.marketdata.app/v1/test/";
15+
private static final String RAY = "ray-1";
16+
17+
// ---------- switch coverage: each case + default ----------
18+
19+
@Test
20+
void status400MapsToBadRequest() {
21+
MarketDataException e = HttpStatusMapper.toException(400, URL, RAY);
22+
assertThat(e).isInstanceOf(BadRequestError.class);
23+
assertThat(e.getStatusCode()).isEqualTo(400);
24+
assertThat(e.getMessage()).contains("400");
25+
}
26+
27+
@Test
28+
void status422AlsoMapsToBadRequest() {
29+
// Same case-arm as 400; without exercising 422 explicitly, half the multi-label arm is
30+
// unrecorded by JaCoCo.
31+
MarketDataException e = HttpStatusMapper.toException(422, URL, RAY);
32+
assertThat(e).isInstanceOf(BadRequestError.class);
33+
assertThat(e.getStatusCode()).isEqualTo(422);
34+
assertThat(e.getMessage()).contains("422");
35+
}
36+
37+
@Test
38+
void status401MapsToAuthenticationError() {
39+
MarketDataException e = HttpStatusMapper.toException(401, URL, RAY);
40+
assertThat(e).isInstanceOf(AuthenticationError.class);
41+
assertThat(e.getStatusCode()).isEqualTo(401);
42+
}
43+
44+
@Test
45+
void status429MapsToRateLimitError() {
46+
MarketDataException e = HttpStatusMapper.toException(429, URL, RAY);
47+
assertThat(e).isInstanceOf(RateLimitError.class);
48+
assertThat(e.getStatusCode()).isEqualTo(429);
49+
}
50+
51+
@Test
52+
void everyOtherStatusFallsThroughToServerError() {
53+
// Any status not explicitly handled (402, 500, 502, 503, 504, weird ones) maps to
54+
// ServerError. Covers the `default ->` arm.
55+
for (int code : new int[] {402, 500, 502, 503, 504, 599}) {
56+
MarketDataException e = HttpStatusMapper.toException(code, URL, RAY);
57+
assertThat(e).as("status %d", code).isInstanceOf(ServerError.class);
58+
assertThat(e.getStatusCode()).isEqualTo(code);
59+
}
60+
}
61+
62+
// ---------- emptyToNull: null vs blank vs valid ----------
63+
64+
@Test
65+
void nullRequestIdIsPropagatedAsNull() {
66+
MarketDataException e = HttpStatusMapper.toException(500, URL, null);
67+
assertThat(e.getRequestId()).isNull();
68+
}
69+
70+
@Test
71+
void blankRequestIdIsTreatedAsNull() {
72+
// emptyToNull's `s == null || s.isBlank()` short-circuits — without an explicit blank
73+
// input, the right-hand isBlank() branch is never evaluated.
74+
MarketDataException e = HttpStatusMapper.toException(500, URL, " ");
75+
assertThat(e.getRequestId()).isNull();
76+
}
77+
78+
@Test
79+
void validRequestIdIsPreserved() {
80+
MarketDataException e = HttpStatusMapper.toException(500, URL, "ray-abc");
81+
assertThat(e.getRequestId()).isEqualTo("ray-abc");
82+
}
83+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.marketdata.sdk.internal.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.fasterxml.jackson.annotation.JsonProperty;
6+
import com.sun.net.httpserver.HttpExchange;
7+
import com.sun.net.httpserver.HttpHandler;
8+
import com.sun.net.httpserver.HttpServer;
9+
import java.io.IOException;
10+
import java.net.InetSocketAddress;
11+
import java.net.URI;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.concurrent.atomic.AtomicReference;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.Test;
17+
18+
/**
19+
* End-to-end tests for {@link HttpTransport} that exercise URI shapes and status codes the public
20+
* resource façades don't naturally hit (status 203, trailing-slash paths). Uses the JDK's built-in
21+
* {@link HttpServer} to avoid any extra mocking dependencies.
22+
*/
23+
class HttpTransportE2ETest {
24+
25+
private HttpServer server;
26+
private final AtomicReference<URI> capturedUri = new AtomicReference<>();
27+
private RouteHandler handler;
28+
29+
/** Minimal record matching {@code {"value": "..."}} so we can verify a successful decode. */
30+
record Echo(@JsonProperty("value") String value) {}
31+
32+
@BeforeEach
33+
void startServer() throws IOException {
34+
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
35+
handler = new RouteHandler();
36+
server.createContext("/", handler);
37+
server.start();
38+
}
39+
40+
@AfterEach
41+
void stopServer() {
42+
server.stop(0);
43+
}
44+
45+
private HttpTransport newTransport() {
46+
int port = server.getAddress().getPort();
47+
return new HttpTransport("http://127.0.0.1:" + port, "v1", "test/0.0", null);
48+
}
49+
50+
/**
51+
* Status 203 (Non-Authoritative Information) is treated identically to 200 by the transport —
52+
* decoding the body and returning the result. The check {@code status == 200 || status == 203 ||
53+
* status == 404} in {@code processResponse} is the only place 203 appears, and without an
54+
* explicit test the 203 leg is dead from JaCoCo's perspective.
55+
*/
56+
@Test
57+
void status203IsTreatedAsSuccess() {
58+
handler.setResponse(203, "{\"value\":\"ok\"}");
59+
60+
Echo result = newTransport().executeSync(RequestSpec.get("ping").build(), Echo.class);
61+
62+
assertThat(result.value()).isEqualTo("ok");
63+
}
64+
65+
/**
66+
* When the {@link RequestSpec#path()} already ends with a slash, the transport must not append
67+
* another one. Covers the {@code endsWith("/")} → true branch in {@code buildUri}.
68+
*/
69+
@Test
70+
void pathEndingInSlashIsNotDoubled() {
71+
handler.setResponse(200, "{\"value\":\"ok\"}");
72+
73+
Echo result = newTransport().executeSync(RequestSpec.get("ping/").build(), Echo.class);
74+
75+
assertThat(result.value()).isEqualTo("ok");
76+
assertThat(capturedUri.get().getPath()).isEqualTo("/v1/ping/");
77+
assertThat(capturedUri.get().getPath()).doesNotContain("//");
78+
}
79+
80+
// ---------- in-process server plumbing ----------
81+
82+
private final class RouteHandler implements HttpHandler {
83+
private int statusCode = 200;
84+
private String body = "{}";
85+
86+
void setResponse(int code, String body) {
87+
this.statusCode = code;
88+
this.body = body;
89+
}
90+
91+
@Override
92+
public void handle(HttpExchange exchange) throws IOException {
93+
capturedUri.set(exchange.getRequestURI());
94+
95+
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
96+
exchange.getResponseHeaders().add("Content-Type", "application/json");
97+
exchange.sendResponseHeaders(statusCode, bodyBytes.length);
98+
exchange.getResponseBody().write(bodyBytes);
99+
exchange.getResponseBody().close();
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)