Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
name: Build and Test
name: Build, Test, and Package

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:
Expand All @@ -13,12 +12,16 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
submodules: true

- name: Set up JDK 11
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'
cache: maven
- name: Build with Maven

- name: Build, test and package
run: mvn -B package --file pom.xml

3 changes: 3 additions & 0 deletions .gitmodules
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
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,20 @@
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<version>3.5.4</version>
<scope>test</scope>
</dependency>
</dependencies>

</project>
1 change: 1 addition & 0 deletions specification
Submodule specification added at 57495a
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@

public class OctopusConfiguration {
private final String clientIdentifier;
private static final String DEFAULT_SERVER_URI = "https://features.octopus.com";
private Duration cacheDuration = Duration.ofMinutes(1);
private static final URI DEFAULT_SERVER_URI = URI.create("https://features.octopus.com");
private URI serverUri = DEFAULT_SERVER_URI;
private Duration cacheDuration = Duration.ofMinutes(1);

public OctopusConfiguration(String clientIdentifier) {
this.clientIdentifier = clientIdentifier;
}

public String getClientIdentifier() { return clientIdentifier; }

public URI getServerUri() { return URI.create(DEFAULT_SERVER_URI); }

public URI getServerUri() { return serverUri; }

// Note: package-private by default. Visible to tests in same package, but not to library consumers.
void setServerUri(URI serverUri) { this.serverUri = serverUri; }

public Duration getCacheDuration() {
return cacheDuration;
}

public Duration setCacheDuration(Duration cacheDuration) {
this.cacheDuration = cacheDuration;
return this.cacheDuration;
Expand Down
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);
}
}
62 changes: 62 additions & 0 deletions src/test/java/com/octopus/openfeature/provider/Server.java
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();
}
}
172 changes: 172 additions & 0 deletions src/test/java/com/octopus/openfeature/provider/SpecificationTests.java
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();
}
Comment on lines +31 to +48
Copy link

Copilot AI Mar 27, 2026

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 (OpenFeatureAPI singleton and a static Server with 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.

Copilot uses AI. Check for mistakes.

@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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required for the RawJsonDeserializer below.

.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
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream.of(fixture.cases) creates a Stream<FixtureCase[]> (single element: the array), so c.description won’t compile/work. Use an element stream (e.g., Arrays.stream(fixture.cases)) so each FixtureCase becomes a separate Arguments row.

Copilot uses AI. Check for mistakes.
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> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jackson does not have the equivalent of GetRawText. I could have fetched a generic JSON object and then reserialised it, but I'd prefer to get the raw text from the file to avoid unintentional changes to the "response".

@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
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This “slice raw source by char offsets” approach is fragile (offset semantics vary by parser/source, begin - 1 can underflow, getRawContent().toString() is not guaranteed to be the actual JSON text, and (int) casts can truncate). A more robust approach is to deserialize response as a JsonNode (or Object) and then serialize it when configuring the server (e.g., mapper.writeValueAsString(node)), avoiding any dependency on raw input offsets.

Copilot uses AI. Check for mistakes.

}
Loading