diff --git a/.travis.yml b/.travis.yml
index f0db4f2c..f9183d47 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,6 +2,8 @@ language: java
sudo: required
jdk:
- oraclejdk8
+services:
+ - docker
cache:
directories:
- $HOME/.m2
@@ -13,4 +15,4 @@ before_script:
- export M2_HOME=$PWD/apache-maven-3.5.4
- export PATH=$PWD/apache-maven-3.5.4/bin:$PATH
script:
- - mvn package install
+ - mvn verify -P integration-test
diff --git a/install/docker/Dockerfile b/install/docker/Dockerfile
index 83068a77..d3a00260 100644
--- a/install/docker/Dockerfile
+++ b/install/docker/Dockerfile
@@ -16,6 +16,7 @@ RUN apk --no-cache add \
ttf-dejavu \
ca-certificates \
tini \
+ curl \
openjdk8-jre
COPY run.sh /usr/local/bin/run.sh
@@ -28,6 +29,6 @@ EXPOSE $AIRSONIC_PORT
VOLUME $AIRSONIC_DIR/data $AIRSONIC_DIR/music $AIRSONIC_DIR/playlists $AIRSONIC_DIR/podcasts
-HEALTHCHECK CMD wget -q http://localhost:"$AIRSONIC_PORT""$CONTEXT_PATH"/rest/ping -O /dev/null || exit 1
+HEALTHCHECK --interval=15s --timeout=3s CMD wget -q http://localhost:"$AIRSONIC_PORT""$CONTEXT_PATH"rest/ping -O /dev/null || exit 1
ENTRYPOINT ["tini", "--", "run.sh"]
diff --git a/integration-test/pom.xml b/integration-test/pom.xml
new file mode 100644
index 00000000..00f96fc3
--- /dev/null
+++ b/integration-test/pom.xml
@@ -0,0 +1,205 @@
+
+
+ 4.0.0
+ airsonic-integration-test
+ Airsonic Integration Test
+
+
+ org.airsonic.player
+ airsonic
+ 10.2.0-SNAPSHOT
+
+
+
+ UTF-8
+ 2.3.1
+
+
+
+
+ io.cucumber
+ cucumber-core
+ ${cucumber.version}
+ test
+
+
+ io.cucumber
+ cucumber-junit
+ ${cucumber.version}
+ test
+
+
+ io.cucumber
+ cucumber-java
+ ${cucumber.version}
+ test
+
+
+ io.cucumber
+ cucumber-java8
+ ${cucumber.version}
+ test
+
+
+ io.cucumber
+ cucumber-spring
+ ${cucumber.version}
+ test
+
+
+ org.springframework
+ spring-context
+ test
+
+
+ org.springframework
+ spring-test
+
+
+ junit
+ junit
+ 4.12
+ test
+
+
+ org.apache.commons
+ commons-lang3
+ test
+
+
+ commons-codec
+ commons-codec
+ test
+
+
+ commons-io
+ commons-io
+ test
+
+
+ org.apache.httpcomponents
+ httpcore
+ test
+
+
+ org.apache.httpcomponents
+ httpclient
+ test
+
+
+ com.spotify
+ docker-client
+ 8.13.1
+ test
+
+
+ org.slf4j
+ slf4j-api
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+ com.google.guava
+ guava
+ test
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ test
+
+
+ org.xmlunit
+ xmlunit-core
+ 2.6.0
+ test
+
+
+ org.xmlunit
+ xmlunit-matchers
+ 2.6.0
+ test
+
+
+ org.airsonic.player
+ subsonic-rest-api
+ ${project.version}
+ test
+
+
+
+
+
+
+ src/test/resources
+ true
+
+
+
+
+ com.github.temyers
+ cucumber-jvm-parallel-plugin
+ 5.0.0
+
+
+ generateRunners
+ generate-test-sources
+
+ generateRunners
+
+
+ src/test/resources/features
+ pretty
+ org.airsonic.test.cucumber.steps
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.5.1
+
+
+ 1.8
+ 1.8
+ true
+ UTF-8
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.1.0
+
+
+ default-jar
+
+
+ unwanted
+ unwanted
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 2.21.0
+
+ all
+ 2
+
+ **/Parallel*IT.class
+
+
+
+
+
+
+
diff --git a/integration-test/src/test/java/org/airsonic/test/SpringContext.java b/integration-test/src/test/java/org/airsonic/test/SpringContext.java
new file mode 100644
index 00000000..e38c6aef
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/SpringContext.java
@@ -0,0 +1,12 @@
+package org.airsonic.test;
+
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
+
+@Configuration
+@ComponentScan("org.airsonic.test")
+@PropertySource("classpath:application.properties")
+public class SpringContext {
+
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/RunCukesTest.java b/integration-test/src/test/java/org/airsonic/test/cucumber/RunCukesTest.java
new file mode 100644
index 00000000..821959ac
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/RunCukesTest.java
@@ -0,0 +1,13 @@
+package org.airsonic.test.cucumber;
+
+import cucumber.api.CucumberOptions;
+import cucumber.api.junit.Cucumber;
+import org.junit.runner.RunWith;
+
+@RunWith(Cucumber.class)
+@CucumberOptions(plugin = {"pretty"},
+ features = "classpath:features/api/stream-mp3.feature",
+ glue = "org.airsonic.test.cucumber.steps")
+public class RunCukesTest {
+
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/server/AirsonicServer.java b/integration-test/src/test/java/org/airsonic/test/cucumber/server/AirsonicServer.java
new file mode 100644
index 00000000..b3e652d4
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/server/AirsonicServer.java
@@ -0,0 +1,20 @@
+package org.airsonic.test.cucumber.server;
+
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.http.client.methods.RequestBuilder;
+
+import java.nio.file.Path;
+
+public interface AirsonicServer {
+ String getBaseUri();
+
+ void uploadToDefaultMusicFolder(Path directoryPath, String relativePath);
+
+ default void addRestParameters(RequestBuilder builder) {
+ builder.addParameter("c", "inttest");
+ builder.addParameter("v", "1.15.0");
+ builder.addParameter("u", "admin");
+ builder.addParameter("s", "int");
+ builder.addParameter("t", DigestUtils.md5Hex("admin" + "int"));
+ }
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/PingStepDef.java b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/PingStepDef.java
new file mode 100644
index 00000000..c66142db
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/PingStepDef.java
@@ -0,0 +1,50 @@
+package org.airsonic.test.cucumber.steps.api;
+
+import cucumber.api.java8.En;
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.apache.http.HttpEntity;
+import org.apache.http.StatusLine;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.util.EntityUtils;
+import org.xmlunit.builder.Input;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.xmlunit.matchers.CompareMatcher.isIdenticalTo;
+
+public class PingStepDef implements En {
+
+ private CloseableHttpResponse response;
+ private CloseableHttpClient client;
+
+ public PingStepDef(AirsonicServer server) {
+ this.client = HttpClientBuilder.create().build();
+ When("^A ping request is sent$", () -> {
+ HttpGet httpGet = new HttpGet(server.getBaseUri() + "/rest/ping");
+ this.response = client.execute(httpGet);
+ });
+ Then("^A required parameter response is received$", () -> {
+ if(response == null) {
+ throw new IllegalStateException();
+ }
+ try {
+ StatusLine statusLine = response.getStatusLine();
+ assertEquals(statusLine.getStatusCode(), 200);
+ HttpEntity entity = response.getEntity();
+ String actual = EntityUtils.toString(entity);
+ assertThat(
+ actual,
+ isIdenticalTo(
+ Input.fromStream(
+ getClass().getResourceAsStream("/blobs/ping/missing-auth.xml")))
+ .ignoreWhitespace());
+ } finally {
+ response.close();
+ }
+ });
+ }
+
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/ScanStepDef.java b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/ScanStepDef.java
new file mode 100644
index 00000000..784e43e6
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/ScanStepDef.java
@@ -0,0 +1,61 @@
+package org.airsonic.test.cucumber.steps.api;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.MapperFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import cucumber.api.java8.En;
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.RequestBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.util.EntityUtils;
+import org.junit.Assert;
+import org.subsonic.restapi.Response;
+
+import java.io.IOException;
+
+public class ScanStepDef implements En {
+
+ private final AirsonicServer server;
+ private CloseableHttpResponse response;
+ private CloseableHttpClient client;
+ private ObjectMapper mapper = new ObjectMapper();
+
+ public ScanStepDef(AirsonicServer server) {
+ mapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
+ this.client = HttpClientBuilder.create().build();
+ this.server = server;
+
+ Given("a scan is done", () -> {
+ Assert.assertFalse(isScanning());
+
+ RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/startScan");
+ server.addRestParameters(builder);
+ response = client.execute(builder.build());
+ System.out.println(EntityUtils.toString(response.getEntity()));
+ Long waitTime = 30000L;
+ Long sleepTime = 1000L;
+ while(waitTime > 0 && isScanning()) {
+ waitTime -= sleepTime;
+ Thread.sleep(sleepTime);
+ }
+
+ Assert.assertFalse(isScanning());
+ });
+
+ }
+
+ private boolean isScanning() throws IOException {
+ RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/getScanStatus");
+ builder.addParameter("f", "json");
+ server.addRestParameters(builder);
+ response = client.execute(builder.build());
+
+ String responseAsString = EntityUtils.toString(response.getEntity());
+ JsonNode jsonNode = mapper.readTree(responseAsString).get("subsonic-response");
+ Response response = mapper.treeToValue(jsonNode, Response.class);
+ return response.getScanStatus().isScanning();
+ }
+
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/SpringStepDef.java b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/SpringStepDef.java
new file mode 100644
index 00000000..b1ff303f
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/SpringStepDef.java
@@ -0,0 +1,20 @@
+package org.airsonic.test.cucumber.steps.api;
+
+import cucumber.api.java.Before;
+import org.airsonic.test.SpringContext;
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.springframework.test.context.ContextConfiguration;
+
+@ContextConfiguration(classes = SpringContext.class)
+public class SpringStepDef {
+
+ public SpringStepDef(AirsonicServer server) {
+
+ }
+
+ @Before
+ public void setup_cucumber_spring_context(){
+ // Dummy method so cucumber will recognize this class as glue
+ // and use its context configuration.
+ }
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java
new file mode 100644
index 00000000..39aedfb4
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java
@@ -0,0 +1,69 @@
+package org.airsonic.test.cucumber.steps.api;
+
+import cucumber.api.java8.En;
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.HexDump;
+import org.apache.commons.io.IOUtils;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.RequestBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.util.EntityUtils;
+import org.junit.Assert;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Paths;
+
+public class StreamStepDef implements En {
+
+ private CloseableHttpResponse response;
+ private CloseableHttpClient client;
+ private boolean closed = false;
+ private byte[] body;
+
+ public StreamStepDef(AirsonicServer server) {
+ this.client = HttpClientBuilder.create().build();
+ Given("Media file (.*) is added", (String mediaFile) -> {
+ // TODO fix this
+ server.uploadToDefaultMusicFolder(
+ Paths.get(this.getClass().getResource("/blobs/stream/piano").toURI()),
+ "");
+ });
+
+ When("A stream request is sent", () -> {
+ RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/stream");
+ builder.addParameter("id", "2");
+ builder.addHeader("Range", "bytes=0-");
+ builder.addHeader("Accept", "audio/webm,audio/ogg,audio/wav,audio/*;");
+ server.addRestParameters(builder);
+ response = client.execute(builder.build());
+ });
+
+ Then("The response bytes are equal", () -> {
+ ensureBodyRead();
+
+ FileUtils.writeByteArrayToFile(new File("/tmp/bytearray"), body);
+
+ byte[] expected = IOUtils.toByteArray(this.getClass().getResource("/blobs/stream/piano/piano.mp3").toURI());
+//
+// HexDump.dump(expected, 0, System.out, 0);
+
+ Assert.assertArrayEquals(expected, body);
+ });
+
+
+ }
+
+ void ensureBodyRead() throws IOException {
+ if(closed) {
+ return;
+ } else {
+ this.body = EntityUtils.toByteArray(response.getEntity());
+ closed = true;
+ response.close();
+ }
+ }
+
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/DynamicDockerHook.java b/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/DynamicDockerHook.java
new file mode 100644
index 00000000..4c08de5d
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/DynamicDockerHook.java
@@ -0,0 +1,158 @@
+package org.airsonic.test.cucumber_hooks.docker;
+
+import com.spotify.docker.client.DefaultDockerClient;
+import com.spotify.docker.client.DockerClient;
+import com.spotify.docker.client.exceptions.DockerException;
+import com.spotify.docker.client.messages.*;
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.context.EnvironmentAware;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+import static com.spotify.docker.client.DockerClient.RemoveContainerParam.*;
+
+@Component
+@Profile("dynamic")
+public class DynamicDockerHook implements AirsonicServer, EnvironmentAware, InitializingBean, DisposableBean {
+
+ private static final Logger logger = LoggerFactory.getLogger(DynamicDockerHook.class);
+ public static final String AIRSONIC_DOCKER_IMAGE = "airsonic.docker.image";
+ public static final String AIRSONIC_DOCKER_PORT = "airsonic.docker.port";
+ public static final String AIRSONIC_READY_MAX_WAIT = "airsonic.ready.max_wait";
+ public static final String AIRSONIC_READY_SLEEP_TIME = "airsonic.ready.sleep_time";
+
+ private String serverUri = null;
+ private final DockerClient docker;
+ private String containerId;
+ private String dockerImage;
+ private Integer dockerPort;
+ private Long readyMaxWaitTime;
+ private Long readySleepTime;
+
+ public DynamicDockerHook() {
+ logger.debug("Using hook for dynamically creating containers");
+ docker = new DefaultDockerClient("unix:///var/run/docker.sock");
+ testDockerIsAvail();
+ }
+
+ private void testDockerIsAvail() {
+ try {
+ logger.trace("Trying to ping docker daemon");
+ docker.ping();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public String getBaseUri() {
+ if (serverUri == null) {
+ throw new IllegalStateException("Server is not yet ready");
+ } else {
+ return serverUri;
+ }
+ }
+
+ @Override
+ public void uploadToDefaultMusicFolder(Path localDir, String relativePath) {
+ try {
+ // TODO ensure localDir is a directory
+ docker.copyToContainer(localDir, containerId, "/airsonic/music/" + relativePath);
+ } catch (DockerException | IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void startServer() {
+ logger.debug("Starting server");
+ final ContainerConfig config = ContainerConfig.builder()
+ .image(dockerImage)
+ .build();
+
+ final String name = "airsonic-it-" + RandomStringUtils.randomAlphabetic(10);
+ try {
+ final ContainerCreation containerCreate = docker.createContainer(config, name);
+ containerId = containerCreate.id();
+ docker.startContainer(containerId);
+ Long waitTime = readyMaxWaitTime;
+ while(true) {
+ ContainerInfo containerInfo = docker.inspectContainer(containerId);
+ ContainerState.Health health = containerInfo.state().health();
+ if (health != null && StringUtils.equalsIgnoreCase(health.status(), "healthy")) {
+ logger.trace("Container started early. Yay!");
+ break;
+ } else if(waitTime > readySleepTime) {
+ if(logger.isTraceEnabled()) {
+ String message;
+ if(health != null) {
+ message = "Container ("+name+") not yet ready. State was: " + health.status();
+ } else {
+ message = "Container ("+name+") state unknown. Waiting";
+ }
+ logger.trace(message);
+ }
+ waitTime -= readySleepTime;
+ Thread.sleep(readySleepTime);
+ } else if(health == null) {
+ logger.trace("Max wait time with unknown container state. Hoping container is ready");
+ break;
+ } else {
+ logger.trace("Container ("+name+") never became ready within max wait time");
+ throw new RuntimeException("Container ("+name+") not ready");
+ }
+ }
+ ContainerInfo containerInfo = docker.inspectContainer(containerId);
+ try {
+ Map.Entry next = containerInfo.networkSettings().networks().entrySet().iterator().next();
+ String ipAddress = next.getValue().ipAddress();
+ serverUri = "http://" + ipAddress + ":" + dockerPort;
+ } catch(Exception e) {
+ throw new RuntimeException("Could not determine container ("+name+") address", e);
+ }
+ } catch (DockerException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void stopServer() {
+ if(containerId != null) {
+ try {
+ docker.removeContainer(containerId, forceKill(), removeVolumes());
+ } catch (DockerException | InterruptedException e) {
+ throw new RuntimeException("Could not remove container", e);
+ }
+ }
+ }
+
+ @Override
+ public void setEnvironment(Environment environment) {
+ dockerImage = environment.getRequiredProperty(AIRSONIC_DOCKER_IMAGE);
+ dockerPort = Integer.parseInt(environment.getRequiredProperty(AIRSONIC_DOCKER_PORT));
+ readyMaxWaitTime = Long.parseLong(environment.getRequiredProperty(AIRSONIC_READY_MAX_WAIT));
+ readySleepTime = Long.parseLong(environment.getRequiredProperty(AIRSONIC_READY_SLEEP_TIME));
+ if(readyMaxWaitTime <= 0L || readySleepTime <= 0L) {
+ throw new IllegalArgumentException("Max wait time and sleep time must be greater than 0");
+ }
+ }
+
+ @Override
+ public void destroy() throws Exception {
+ stopServer();
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ startServer();
+ }
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/ExistingDockerHook.java b/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/ExistingDockerHook.java
new file mode 100644
index 00000000..27592917
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/ExistingDockerHook.java
@@ -0,0 +1,98 @@
+package org.airsonic.test.cucumber_hooks.docker;
+
+import com.spotify.docker.client.DefaultDockerClient;
+import com.spotify.docker.client.DockerClient;
+import com.spotify.docker.client.exceptions.DockerException;
+import com.spotify.docker.client.messages.AttachedNetwork;
+import com.spotify.docker.client.messages.ContainerInfo;
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.context.EnvironmentAware;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+@Component
+@Profile("existing")
+public class ExistingDockerHook implements AirsonicServer, EnvironmentAware, InitializingBean {
+
+ private static final Logger logger = LoggerFactory.getLogger(ExistingDockerHook.class);
+ public static final String AIRSONIC_DOCKER_CONTAINER = "airsonic.docker.container";
+ public static final String AIRSONIC_DOCKER_PORT = "airsonic.docker.port";
+
+ private String serverUri = null;
+ private final DockerClient docker;
+ private String containerId;
+ private Integer dockerPort;
+
+ public ExistingDockerHook() {
+ logger.debug("Using hook for existing docker container");
+ docker = new DefaultDockerClient("unix:///var/run/docker.sock");
+ testDockerIsAvail();
+ }
+
+ private void testDockerIsAvail() {
+ try {
+ logger.trace("Trying to ping docker daemon");
+ docker.ping();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public String getBaseUri() {
+ if (serverUri == null) {
+ throw new IllegalStateException("Server is not yet ready");
+ } else {
+ return serverUri;
+ }
+ }
+
+ @Override
+ public void uploadToDefaultMusicFolder(Path localDir, String relativePath) {
+ try {
+ // TODO ensure localDir is a directory
+ docker.copyToContainer(localDir, containerId, "/airsonic/music/" + relativePath);
+ } catch (DockerException | IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void connectToServer() {
+ logger.debug("Connecting to server");
+
+ try {
+ ContainerInfo containerInfo = docker.inspectContainer(containerId);
+ if(!containerInfo.state().running()) {
+ throw new IllegalStateException("Container is not running " + containerId);
+ }
+ Map.Entry next = containerInfo.networkSettings().networks().entrySet().iterator().next();
+ String ipAddress = next.getValue().ipAddress();
+ if(StringUtils.isBlank(ipAddress)) {
+ throw new IllegalStateException("No address found for container " + containerId);
+ }
+ serverUri = "http://" + ipAddress + ":" + dockerPort;
+ } catch (DockerException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void setEnvironment(Environment environment) {
+ containerId = environment.getRequiredProperty(AIRSONIC_DOCKER_CONTAINER);
+ dockerPort = Integer.parseInt(environment.getRequiredProperty(AIRSONIC_DOCKER_PORT));
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ connectToServer();
+ }
+}
diff --git a/integration-test/src/test/resources/application.properties b/integration-test/src/test/resources/application.properties
new file mode 100644
index 00000000..95c1423a
--- /dev/null
+++ b/integration-test/src/test/resources/application.properties
@@ -0,0 +1,12 @@
+airsonic.docker.port=4040
+airsonic.ready.max_wait=80000
+airsonic.ready.sleep_time=5000
+
+# Use for dynamically generating a container as needed
+spring.profiles.active=dynamic
+airsonic.docker.image=airsonic/airsonic:${project.version}
+
+# Use for testing against an existing/running container
+#airsonic.docker.container=1212be8a94e0
+#spring.profiles.active=existing
+
diff --git a/integration-test/src/test/resources/blobs/ping/missing-auth.xml b/integration-test/src/test/resources/blobs/ping/missing-auth.xml
new file mode 100644
index 00000000..7199e614
--- /dev/null
+++ b/integration-test/src/test/resources/blobs/ping/missing-auth.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/integration-test/src/test/resources/blobs/ping/ok.xml b/integration-test/src/test/resources/blobs/ping/ok.xml
new file mode 100644
index 00000000..54302310
--- /dev/null
+++ b/integration-test/src/test/resources/blobs/ping/ok.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/integration-test/src/test/resources/blobs/stream/piano/piano.mp3 b/integration-test/src/test/resources/blobs/stream/piano/piano.mp3
new file mode 100644
index 00000000..dd253d5d
Binary files /dev/null and b/integration-test/src/test/resources/blobs/stream/piano/piano.mp3 differ
diff --git a/integration-test/src/test/resources/features/api/ping.feature b/integration-test/src/test/resources/features/api/ping.feature
new file mode 100644
index 00000000..ff403766
--- /dev/null
+++ b/integration-test/src/test/resources/features/api/ping.feature
@@ -0,0 +1,5 @@
+Feature: Ping API
+
+ Scenario: Airsonic responds to ping requests
+ When A ping request is sent
+ Then A required parameter response is received
diff --git a/integration-test/src/test/resources/features/api/stream-mp3.feature b/integration-test/src/test/resources/features/api/stream-mp3.feature
new file mode 100644
index 00000000..86fa4358
--- /dev/null
+++ b/integration-test/src/test/resources/features/api/stream-mp3.feature
@@ -0,0 +1,11 @@
+Feature: Stream API for MP3
+
+ Background:
+ Given Media file stream/piano/piano.mp3 is added
+ And a scan is done
+
+ Scenario: Airsonic sends stream data
+ When A stream request is sent
+ Then The response bytes are equal
+ # TODO check length
+
diff --git a/integration-test/src/test/resources/logback.xml b/integration-test/src/test/resources/logback.xml
new file mode 100644
index 00000000..590cf2fc
--- /dev/null
+++ b/integration-test/src/test/resources/logback.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index be66c6d3..2dcd4e7b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -116,12 +116,22 @@
com.fasterxml.jackson.core
jackson-core
- 2.8.11
+ 2.9.6
com.fasterxml.jackson.core
jackson-databind
- 2.8.11.1
+ 2.9.6
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ 2.9.0
+
+
+ com.google.guava
+ guava
+ 20.0
@@ -303,6 +313,16 @@
install/docker
+
+ integration-test
+
+ subsonic-rest-api
+ airsonic-sonos-api
+ airsonic-main
+ install/docker
+ integration-test
+
+
sign