-
Notifications
You must be signed in to change notification settings - Fork 0
Implement shared specifications tests #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e91ebdb
cb9ae1e
b2fdbe1
8ac6675
c4e4123
625a1e6
5f7bb7c
75b86a7
90e62e8
aa7417d
d551595
135bae3
579385d
9a16513
34d46a5
91bab70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| [submodule "specification"] | ||
| path = specification | ||
| url = https://github.com/OctopusDeploy/openfeature-provider-specification.git |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.octopus.openfeature.provider; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.net.URI; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class OctopusConfigurationTests { | ||
|
|
||
| @Test | ||
| void defaultServerUriIsOctopusCloud() { | ||
| var config = new OctopusConfiguration("test-client"); | ||
| assertThat(config.getServerUri()).isEqualTo(URI.create("https://features.octopus.com")); | ||
| } | ||
|
|
||
| @Test | ||
| void serverUriCanBeOverridden() { | ||
| var config = new OctopusConfiguration("test-client"); | ||
| var customUri = URI.create("http://localhost:8080"); | ||
| config.setServerUri(customUri); | ||
| assertThat(config.getServerUri()).isEqualTo(customUri); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package com.octopus.openfeature.provider; | ||
|
|
||
| import com.github.tomakehurst.wiremock.WireMockServer; | ||
|
|
||
| import java.util.Base64; | ||
| import java.util.UUID; | ||
|
|
||
| import static com.github.tomakehurst.wiremock.client.WireMock.*; | ||
| import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; | ||
|
|
||
| /** | ||
| * Fake HTTP server for specification tests. | ||
| * | ||
| * Each call to {@link #configure(String)} registers a stub for a unique Bearer token | ||
| * and returns that token as the client identifier. Stubs accumulate over the server's | ||
| * lifetime (one per test case), which is harmless since each token is unique. | ||
| * | ||
| * Note: parallel test execution is not supported because SpecificationTests uses | ||
| * the OpenFeatureAPI singleton. | ||
| */ | ||
| class Server { | ||
|
|
||
| // A fixed hash is safe here because each test shuts down the provider via OpenFeatureAPI.shutdown() | ||
| // before the background refresh thread can poll the check endpoint and compare hashes. | ||
| private static final String CONTENT_HASH = Base64.getEncoder().encodeToString(new byte[]{0x01}); | ||
| private final WireMockServer wireMock; | ||
|
|
||
| Server() { | ||
| wireMock = new WireMockServer(wireMockConfig().dynamicPort()); | ||
| wireMock.start(); | ||
| // Fallback: return 401 for any request that does not match a registered token. | ||
| wireMock.stubFor(any(anyUrl()) | ||
| .atPriority(100) | ||
| .willReturn(aResponse().withStatus(401))); | ||
| } | ||
|
|
||
| /** | ||
| * Registers the given JSON as the response body for a new unique client token. | ||
| * | ||
| * @param responseJson the JSON array that the toggle API would return | ||
| * @return the client identifier (Bearer token) to use in OctopusConfiguration | ||
| */ | ||
| String configure(String responseJson) { | ||
| String token = UUID.randomUUID().toString(); | ||
| wireMock.stubFor(get(urlPathEqualTo("/api/featuretoggles/v3/")) | ||
| .withHeader("Authorization", equalTo("Bearer " + token)) | ||
| .willReturn(aResponse() | ||
| .withStatus(200) | ||
| .withHeader("Content-Type", "application/json") | ||
| .withHeader("ContentHash", CONTENT_HASH) | ||
| .withBody(responseJson))); | ||
| return token; | ||
| } | ||
|
|
||
| String baseUrl() { | ||
| return wireMock.baseUrl(); | ||
| } | ||
|
|
||
| void stop() { | ||
| wireMock.stop(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| package com.octopus.openfeature.provider; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonParser; | ||
| import com.fasterxml.jackson.core.StreamReadFeature; | ||
| import com.fasterxml.jackson.databind.DeserializationContext; | ||
| import com.fasterxml.jackson.databind.DeserializationFeature; | ||
| import com.fasterxml.jackson.databind.JsonDeserializer; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.fasterxml.jackson.databind.annotation.JsonDeserialize; | ||
| import com.fasterxml.jackson.databind.json.JsonMapper; | ||
| import dev.openfeature.sdk.*; | ||
| import org.junit.jupiter.api.AfterAll; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.junit.jupiter.api.BeforeAll; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.Arguments; | ||
| import org.junit.jupiter.params.provider.MethodSource; | ||
|
|
||
| import java.io.IOException; | ||
| import java.io.UncheckedIOException; | ||
| import java.net.URI; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
| import java.util.stream.Stream; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class SpecificationTests { | ||
|
|
||
| private static Server server; | ||
|
|
||
| @BeforeAll | ||
| static void startServer() { | ||
| server = new Server(); | ||
| } | ||
|
|
||
| @AfterAll | ||
| static void stopServer() { | ||
| server.stop(); | ||
| } | ||
|
|
||
| @AfterEach | ||
| void shutdownApi() { | ||
| OpenFeatureAPI.getInstance().shutdown(); | ||
| } | ||
|
|
||
| @ParameterizedTest(name = "[{0}] {1}") | ||
| @MethodSource("fixtureTestCases") | ||
| void evaluate(String fileName, String description, String responseJson, FixtureCase testCase) { | ||
| String token = server.configure(responseJson); | ||
| OctopusConfiguration config = new OctopusConfiguration(token); | ||
| config.setServerUri(URI.create(server.baseUrl())); | ||
|
|
||
| OpenFeatureAPI api = OpenFeatureAPI.getInstance(); | ||
| api.setProviderAndWait(new OctopusProvider(config)); | ||
| Client client = api.getClient(); | ||
|
|
||
| EvaluationContext ctx = buildContext(testCase.configuration.context); | ||
| FlagEvaluationDetails<Boolean> result = client.getBooleanDetails( | ||
| testCase.configuration.slug, | ||
| testCase.configuration.defaultValue, | ||
| ctx | ||
| ); | ||
|
|
||
| assertThat(result.getValue()) | ||
| .as("[%s] %s → value", fileName, description) | ||
| .isEqualTo(testCase.expected.value); | ||
| assertThat(result.getErrorCode()) | ||
| .as("[%s] %s → errorCode", fileName, description) | ||
| .isEqualTo(mapErrorCode(testCase.expected.errorCode)); | ||
| } | ||
|
|
||
| static Stream<Arguments> fixtureTestCases() throws IOException { | ||
| ObjectMapper mapper = JsonMapper.builder() | ||
| .enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Required for the |
||
| .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) | ||
| .build(); | ||
|
|
||
| List<Path> jsonFiles; | ||
| try (Stream<Path> files = Files.list(Path.of("specification", "Fixtures"))) { | ||
| jsonFiles = files | ||
| .filter(p -> p.getFileName().toString().endsWith(".json")) | ||
| .collect(Collectors.toList()); | ||
| } | ||
| if (jsonFiles.isEmpty()) { | ||
| throw new IllegalStateException( | ||
| "No fixture files found under 'specification/Fixtures/'. " + | ||
| "Ensure the git submodule is initialised: git submodule update --init"); | ||
| } | ||
| return jsonFiles.stream().flatMap(path -> { | ||
| try { | ||
| String fileContent = Files.readString(path); | ||
| Fixture fixture = mapper.readValue(fileContent, Fixture.class); | ||
| String fileName = path.getFileName().toString(); | ||
| return Stream.of(fixture.cases) | ||
| .map(c -> Arguments.of(fileName, c.description, fixture.response, c)); | ||
| } catch (IOException e) { | ||
|
Comment on lines
+93
to
+100
|
||
| throw new UncheckedIOException(e); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private static EvaluationContext buildContext(Map<String, String> context) { | ||
| MutableContext ctx = new MutableContext(); | ||
| if (context != null) { | ||
| context.forEach(ctx::add); | ||
| } | ||
| return ctx; | ||
| } | ||
|
|
||
| private static ErrorCode mapErrorCode(String code) { | ||
| if (code == null) return null; | ||
| switch (code) { | ||
| case "FLAG_NOT_FOUND": | ||
| return ErrorCode.FLAG_NOT_FOUND; | ||
| case "PARSE_ERROR": | ||
| return ErrorCode.PARSE_ERROR; | ||
| case "TYPE_MISMATCH": | ||
| return ErrorCode.TYPE_MISMATCH; | ||
| case "TARGETING_KEY_MISSING": | ||
| return ErrorCode.TARGETING_KEY_MISSING; | ||
| case "PROVIDER_NOT_READY": | ||
| return ErrorCode.PROVIDER_NOT_READY; | ||
| case "INVALID_CONTEXT": | ||
| return ErrorCode.INVALID_CONTEXT; | ||
| case "PROVIDER_FATAL": | ||
| return ErrorCode.PROVIDER_FATAL; | ||
| case "GENERAL": | ||
| return ErrorCode.GENERAL; | ||
| default: | ||
| throw new IllegalArgumentException("Unknown error code in fixture: " + code); | ||
| } | ||
| } | ||
|
|
||
| static class Fixture { | ||
| @JsonDeserialize(using = RawJsonDeserializer.class) | ||
| public String response; | ||
| public FixtureCase[] cases; | ||
| } | ||
|
|
||
| static class FixtureCase { | ||
| public String description; | ||
| public FixtureConfiguration configuration; | ||
| public FixtureExpected expected; | ||
| } | ||
|
|
||
| static class FixtureConfiguration { | ||
| public String slug; | ||
| public boolean defaultValue; | ||
| public Map<String, String> context; | ||
| } | ||
|
|
||
| static class FixtureExpected { | ||
| public boolean value; | ||
| public String errorCode; | ||
| } | ||
|
|
||
| static class RawJsonDeserializer extends JsonDeserializer<String> { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Jackson does not have the equivalent of |
||
| @Override | ||
| public String deserialize(JsonParser jp, DeserializationContext dc) throws IOException { | ||
| long begin = jp.currentLocation().getCharOffset(); | ||
| jp.skipChildren(); | ||
| long end = jp.currentLocation().getCharOffset(); | ||
| String json = jp.currentLocation().contentReference().getRawContent().toString(); | ||
| return json.substring((int) begin - 1, (int) end); | ||
| } | ||
| } | ||
|
Comment on lines
+161
to
+170
|
||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests share global mutable state (
OpenFeatureAPIsingleton and a staticServerwith accumulating stubs). If JUnit parallel execution is enabled (locally or in CI), this can cause cross-test interference/flakiness. Consider explicitly forcing same-thread execution for this class (e.g., JUnit 5@Execution(ExecutionMode.SAME_THREAD)), or otherwise applying a lock/serialization mechanism around these tests.