Skip to content

Commit 51d5af0

Browse files
authored
Implement shared specifications tests (#6)
1 parent ce8282e commit 51d5af0

8 files changed

Lines changed: 284 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
name: Build and Test
1+
name: Build, Test, and Package
22

33
on:
44
push:
55
branches: [ "main" ]
66
pull_request:
7-
branches: [ "main" ]
87

98
jobs:
109
build:
@@ -13,12 +12,16 @@ jobs:
1312

1413
steps:
1514
- uses: actions/checkout@v4
15+
with:
16+
submodules: true
17+
1618
- name: Set up JDK 11
1719
uses: actions/setup-java@v4
1820
with:
1921
java-version: '11'
2022
distribution: 'temurin'
2123
cache: maven
22-
- name: Build with Maven
24+
25+
- name: Build, test and package
2326
run: mvn -B package --file pom.xml
2427

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "specification"]
2+
path = specification
3+
url = https://github.com/OctopusDeploy/openfeature-provider-specification.git

pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,20 @@
119119
<groupId>org.junit.jupiter</groupId>
120120
<artifactId>junit-jupiter</artifactId>
121121
<version>5.11.1</version>
122+
<scope>test</scope>
122123
</dependency>
123124
<dependency>
124125
<groupId>org.assertj</groupId>
125126
<artifactId>assertj-core</artifactId>
126127
<version>3.27.3</version>
127128
<scope>test</scope>
128129
</dependency>
130+
<dependency>
131+
<groupId>org.wiremock</groupId>
132+
<artifactId>wiremock</artifactId>
133+
<version>3.5.4</version>
134+
<scope>test</scope>
135+
</dependency>
129136
</dependencies>
130137

131138
</project>

specification

Submodule specification added at 57495a9

src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,25 @@
55

66
public class OctopusConfiguration {
77
private final String clientIdentifier;
8-
private static final String DEFAULT_SERVER_URI = "https://features.octopus.com";
9-
private Duration cacheDuration = Duration.ofMinutes(1);
8+
private static final URI DEFAULT_SERVER_URI = URI.create("https://features.octopus.com");
9+
private URI serverUri = DEFAULT_SERVER_URI;
10+
private Duration cacheDuration = Duration.ofMinutes(1);
1011

1112
public OctopusConfiguration(String clientIdentifier) {
1213
this.clientIdentifier = clientIdentifier;
1314
}
1415

1516
public String getClientIdentifier() { return clientIdentifier; }
16-
17-
public URI getServerUri() { return URI.create(DEFAULT_SERVER_URI); }
17+
18+
public URI getServerUri() { return serverUri; }
19+
20+
// Note: package-private by default. Visible to tests in same package, but not to library consumers.
21+
void setServerUri(URI serverUri) { this.serverUri = serverUri; }
1822

1923
public Duration getCacheDuration() {
2024
return cacheDuration;
2125
}
22-
26+
2327
public Duration setCacheDuration(Duration cacheDuration) {
2428
this.cacheDuration = cacheDuration;
2529
return this.cacheDuration;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.octopus.openfeature.provider;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.net.URI;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
class OctopusConfigurationTests {
10+
11+
@Test
12+
void defaultServerUriIsOctopusCloud() {
13+
var config = new OctopusConfiguration("test-client");
14+
assertThat(config.getServerUri()).isEqualTo(URI.create("https://features.octopus.com"));
15+
}
16+
17+
@Test
18+
void serverUriCanBeOverridden() {
19+
var config = new OctopusConfiguration("test-client");
20+
var customUri = URI.create("http://localhost:8080");
21+
config.setServerUri(customUri);
22+
assertThat(config.getServerUri()).isEqualTo(customUri);
23+
}
24+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.octopus.openfeature.provider;
2+
3+
import com.github.tomakehurst.wiremock.WireMockServer;
4+
5+
import java.util.Base64;
6+
import java.util.UUID;
7+
8+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
9+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
10+
11+
/**
12+
* Fake HTTP server for specification tests.
13+
*
14+
* Each call to {@link #configure(String)} registers a stub for a unique Bearer token
15+
* and returns that token as the client identifier. Stubs accumulate over the server's
16+
* lifetime (one per test case), which is harmless since each token is unique.
17+
*
18+
* Note: parallel test execution is not supported because SpecificationTests uses
19+
* the OpenFeatureAPI singleton.
20+
*/
21+
class Server {
22+
23+
// A fixed hash is safe here because each test shuts down the provider via OpenFeatureAPI.shutdown()
24+
// before the background refresh thread can poll the check endpoint and compare hashes.
25+
private static final String CONTENT_HASH = Base64.getEncoder().encodeToString(new byte[]{0x01});
26+
private final WireMockServer wireMock;
27+
28+
Server() {
29+
wireMock = new WireMockServer(wireMockConfig().dynamicPort());
30+
wireMock.start();
31+
// Fallback: return 401 for any request that does not match a registered token.
32+
wireMock.stubFor(any(anyUrl())
33+
.atPriority(100)
34+
.willReturn(aResponse().withStatus(401)));
35+
}
36+
37+
/**
38+
* Registers the given JSON as the response body for a new unique client token.
39+
*
40+
* @param responseJson the JSON array that the toggle API would return
41+
* @return the client identifier (Bearer token) to use in OctopusConfiguration
42+
*/
43+
String configure(String responseJson) {
44+
String token = UUID.randomUUID().toString();
45+
wireMock.stubFor(get(urlPathEqualTo("/api/featuretoggles/v3/"))
46+
.withHeader("Authorization", equalTo("Bearer " + token))
47+
.willReturn(aResponse()
48+
.withStatus(200)
49+
.withHeader("Content-Type", "application/json")
50+
.withHeader("ContentHash", CONTENT_HASH)
51+
.withBody(responseJson)));
52+
return token;
53+
}
54+
55+
String baseUrl() {
56+
return wireMock.baseUrl();
57+
}
58+
59+
void stop() {
60+
wireMock.stop();
61+
}
62+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package com.octopus.openfeature.provider;
2+
3+
import com.fasterxml.jackson.core.JsonParser;
4+
import com.fasterxml.jackson.core.StreamReadFeature;
5+
import com.fasterxml.jackson.databind.DeserializationContext;
6+
import com.fasterxml.jackson.databind.DeserializationFeature;
7+
import com.fasterxml.jackson.databind.JsonDeserializer;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
10+
import com.fasterxml.jackson.databind.json.JsonMapper;
11+
import dev.openfeature.sdk.*;
12+
import org.junit.jupiter.api.AfterAll;
13+
import org.junit.jupiter.api.AfterEach;
14+
import org.junit.jupiter.api.BeforeAll;
15+
import org.junit.jupiter.params.ParameterizedTest;
16+
import org.junit.jupiter.params.provider.Arguments;
17+
import org.junit.jupiter.params.provider.MethodSource;
18+
19+
import java.io.IOException;
20+
import java.io.UncheckedIOException;
21+
import java.net.URI;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.stream.Collectors;
27+
import java.util.stream.Stream;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
31+
class SpecificationTests {
32+
33+
private static Server server;
34+
35+
@BeforeAll
36+
static void startServer() {
37+
server = new Server();
38+
}
39+
40+
@AfterAll
41+
static void stopServer() {
42+
server.stop();
43+
}
44+
45+
@AfterEach
46+
void shutdownApi() {
47+
OpenFeatureAPI.getInstance().shutdown();
48+
}
49+
50+
@ParameterizedTest(name = "[{0}] {1}")
51+
@MethodSource("fixtureTestCases")
52+
void evaluate(String fileName, String description, String responseJson, FixtureCase testCase) {
53+
String token = server.configure(responseJson);
54+
OctopusConfiguration config = new OctopusConfiguration(token);
55+
config.setServerUri(URI.create(server.baseUrl()));
56+
57+
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
58+
api.setProviderAndWait(new OctopusProvider(config));
59+
Client client = api.getClient();
60+
61+
EvaluationContext ctx = buildContext(testCase.configuration.context);
62+
FlagEvaluationDetails<Boolean> result = client.getBooleanDetails(
63+
testCase.configuration.slug,
64+
testCase.configuration.defaultValue,
65+
ctx
66+
);
67+
68+
assertThat(result.getValue())
69+
.as("[%s] %s → value", fileName, description)
70+
.isEqualTo(testCase.expected.value);
71+
assertThat(result.getErrorCode())
72+
.as("[%s] %s → errorCode", fileName, description)
73+
.isEqualTo(mapErrorCode(testCase.expected.errorCode));
74+
}
75+
76+
static Stream<Arguments> fixtureTestCases() throws IOException {
77+
ObjectMapper mapper = JsonMapper.builder()
78+
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
79+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
80+
.build();
81+
82+
List<Path> jsonFiles;
83+
try (Stream<Path> files = Files.list(Path.of("specification", "Fixtures"))) {
84+
jsonFiles = files
85+
.filter(p -> p.getFileName().toString().endsWith(".json"))
86+
.collect(Collectors.toList());
87+
}
88+
if (jsonFiles.isEmpty()) {
89+
throw new IllegalStateException(
90+
"No fixture files found under 'specification/Fixtures/'. " +
91+
"Ensure the git submodule is initialised: git submodule update --init");
92+
}
93+
return jsonFiles.stream().flatMap(path -> {
94+
try {
95+
String fileContent = Files.readString(path);
96+
Fixture fixture = mapper.readValue(fileContent, Fixture.class);
97+
String fileName = path.getFileName().toString();
98+
return Stream.of(fixture.cases)
99+
.map(c -> Arguments.of(fileName, c.description, fixture.response, c));
100+
} catch (IOException e) {
101+
throw new UncheckedIOException(e);
102+
}
103+
});
104+
}
105+
106+
private static EvaluationContext buildContext(Map<String, String> context) {
107+
MutableContext ctx = new MutableContext();
108+
if (context != null) {
109+
context.forEach(ctx::add);
110+
}
111+
return ctx;
112+
}
113+
114+
private static ErrorCode mapErrorCode(String code) {
115+
if (code == null) return null;
116+
switch (code) {
117+
case "FLAG_NOT_FOUND":
118+
return ErrorCode.FLAG_NOT_FOUND;
119+
case "PARSE_ERROR":
120+
return ErrorCode.PARSE_ERROR;
121+
case "TYPE_MISMATCH":
122+
return ErrorCode.TYPE_MISMATCH;
123+
case "TARGETING_KEY_MISSING":
124+
return ErrorCode.TARGETING_KEY_MISSING;
125+
case "PROVIDER_NOT_READY":
126+
return ErrorCode.PROVIDER_NOT_READY;
127+
case "INVALID_CONTEXT":
128+
return ErrorCode.INVALID_CONTEXT;
129+
case "PROVIDER_FATAL":
130+
return ErrorCode.PROVIDER_FATAL;
131+
case "GENERAL":
132+
return ErrorCode.GENERAL;
133+
default:
134+
throw new IllegalArgumentException("Unknown error code in fixture: " + code);
135+
}
136+
}
137+
138+
static class Fixture {
139+
@JsonDeserialize(using = RawJsonDeserializer.class)
140+
public String response;
141+
public FixtureCase[] cases;
142+
}
143+
144+
static class FixtureCase {
145+
public String description;
146+
public FixtureConfiguration configuration;
147+
public FixtureExpected expected;
148+
}
149+
150+
static class FixtureConfiguration {
151+
public String slug;
152+
public boolean defaultValue;
153+
public Map<String, String> context;
154+
}
155+
156+
static class FixtureExpected {
157+
public boolean value;
158+
public String errorCode;
159+
}
160+
161+
static class RawJsonDeserializer extends JsonDeserializer<String> {
162+
@Override
163+
public String deserialize(JsonParser jp, DeserializationContext dc) throws IOException {
164+
long begin = jp.currentLocation().getCharOffset();
165+
jp.skipChildren();
166+
long end = jp.currentLocation().getCharOffset();
167+
String json = jp.currentLocation().contentReference().getRawContent().toString();
168+
return json.substring((int) begin - 1, (int) end);
169+
}
170+
}
171+
172+
}

0 commit comments

Comments
 (0)