getRecentBroadcasts(Channel channel) throws IOException {
return broadcastMapper.map(recentBroadcastsDto);
}
- public static BroadcastRepository init(Configuration config) {
+ /**
+ * Update broadcasts with new data from SomaFM.
+ *
+ * The SQL code inserts a song only if it doesn't exist, but always return its ID. This ID is then referenced in the
+ * broadcasts table.
+ */
+ @Override
+ public void updateBroadcasts(Set broadcasts) {
+ var sql = """
+ WITH upsert_song AS(
+ INSERT INTO songs (artist, title, album)
+ VALUES (?, ?, ?)
+ ON CONFLICT DO NOTHING
+ RETURNING id AS new_song_id
+ ),
+ select_song_id AS(
+ SELECT new_song_id FROM upsert_song
+ UNION
+ SELECT id FROM songs WHERE artist=? AND title=? AND album=?
+ )
+ INSERT INTO broadcasts (utc_time, channel, song_id)
+ SELECT ?, ?, new_song_id FROM select_song_id
+ ON CONFLICT DO NOTHING;""";
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ for (Broadcast broadcast : broadcasts) {
+ statement.setString(1, broadcast.getSong().getArtist());
+ statement.setString(2, broadcast.getSong().getTitle());
+ statement.setString(3, broadcast.getSong().getAlbum());
+ statement.setString(4, broadcast.getSong().getArtist());
+ statement.setString(5, broadcast.getSong().getTitle());
+ statement.setString(6, broadcast.getSong().getAlbum());
+ statement.setTimestamp(7, Timestamp.from(broadcast.getTime()));
+ statement.setString(8, broadcast.getChannel().name());
+ statement.addBatch();
+ }
+ statement.executeBatch();
+ } catch (SQLException e) {
+ log.error("Error", e);
+ }
+ }
+
+ public static BroadcastRepository init(SomaFmConfig config) {
RecentBroadcastsClient client = RecentBroadcastsClient.init(config);
- BroadcastMapper mapper = new BroadcastMapper(Clock.systemDefaultZone());
- return new DefaultBroadcastRepository(client, mapper);
+ var mapper = new BroadcastMapper(Clock.systemDefaultZone());
+ var hikariConfig = new HikariConfig();
+ hikariConfig.setJdbcUrl(config.getDbUrl());
+ hikariConfig.setUsername(config.getDbUser());
+ hikariConfig.setPassword(config.getDbPassword());
+ var hikariDataSource = new HikariDataSource(hikariConfig);
+ return new DefaultBroadcastRepository(client, mapper, hikariDataSource);
}
}
diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf
index 5fc6869..bdaa182 100644
--- a/src/main/resources/application.conf
+++ b/src/main/resources/application.conf
@@ -1,4 +1,7 @@
config {
somaFmBaseUrl = "https://somafm.com/"
userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36"
+ dbUrl = ${DB_URL}
+ dbUser = ${DB_USER}
+ dbPassword = ${DB_PASSWORD}
}
diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql
new file mode 100644
index 0000000..d6aeca6
--- /dev/null
+++ b/src/main/resources/db/migration/V1__init.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS songs
+(
+ id SERIAL UNIQUE,
+ artist VARCHAR(255),
+ title VARCHAR(255),
+ album VARCHAR(255),
+ PRIMARY KEY (artist, title, album)
+);
+
+CREATE TABLE IF NOT EXISTS broadcasts
+(
+ utc_time TIMESTAMPTZ,
+ channel VARCHAR(255),
+ song_id INT REFERENCES songs(id),
+ PRIMARY KEY (utc_time, channel)
+);
diff --git a/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java b/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java
index b7903fd..5f9714d 100644
--- a/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java
+++ b/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java
@@ -1,6 +1,7 @@
package net.lecigne.somafm.business;
import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemOut;
+import static net.lecigne.somafm.business.BusinessAction.DISPLAY;
import static net.lecigne.somafm.fixtures.TestFixtures.breakSongFixture;
import static net.lecigne.somafm.fixtures.TestFixtures.dirkSerriesSongFixture;
import static net.lecigne.somafm.fixtures.TestFixtures.igneousFlameSongFixture;
@@ -19,7 +20,9 @@
import net.lecigne.somafm.model.Broadcast;
import net.lecigne.somafm.repository.BroadcastRepository;
import net.lecigne.somafm.repository.DefaultBroadcastRepository;
+import nl.altindag.log.LogCaptor;
import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -28,69 +31,78 @@ class RecentBroadcastBusinessTest {
private final DisplayedBroadcastMapper mapper = new DisplayedBroadcastMapper(ZoneId.of("Europe/Paris"));
- @Test
- void should_display_a_list_of_recent_broadcasts() throws Exception {
- // Given
- BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class);
- given(repository.getRecentBroadcasts(any())).willReturn(Set.of(
- Broadcast.builder()
- .time(Instant.parse("2021-01-01T13:00:00.00Z"))
- .channel(DRONE_ZONE)
- .song(dirkSerriesSongFixture())
- .build(),
- Broadcast.builder()
- .time(Instant.parse("2021-01-01T13:15:00.00Z"))
- .channel(DRONE_ZONE)
- .song(igneousFlameSongFixture())
- .build(),
- Broadcast.builder()
- .time(Instant.parse("2021-01-01T13:20:00.00Z"))
- .channel(DRONE_ZONE)
- .song(breakSongFixture())
- .build()));
- var business = new RecentBroadcastBusiness(repository, mapper);
- var expectedOutput = """
- [2021-01-01 14:20:00 @ Drone Zone] Break / Station ID
- [2021-01-01 14:15:00 @ Drone Zone] Igneous Flame - Incandescent Arc
- [2021-01-01 14:00:00 @ Drone Zone] Dirk Serries' Microphonics - VI"""
- .replaceAll("[\\r\\n]", "");
-
- // When
- Statement statement = () -> business.displayRecentBroadcasts(DRONE_ZONE.getPublicName());
- String actualOutput = tapSystemOut(statement).replaceAll("[\\r\\n]", "");
-
- // Then
- assertThat(actualOutput).isEqualTo(expectedOutput);
- }
+ @Nested
+ class when_displaying_broadcasts {
- @Test
- void should_display_an_informative_message_if_broadcast_retrieval_fails() throws Exception {
- // Given
- BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class);
- given(repository.getRecentBroadcasts(any())).willThrow(IOException.class);
- var business = new RecentBroadcastBusiness(repository, mapper);
- var expectedOutput = "Unable to get recent broadcast from SomaFM at this time. Please try again later.";
+ @Test
+ void should_display_a_list_of_recent_broadcasts() throws Exception {
+ // Given
+ BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class);
+ given(repository.getRecentBroadcasts(any())).willReturn(Set.of(
+ Broadcast.builder()
+ .time(Instant.parse("2021-01-01T13:00:00.00Z"))
+ .channel(DRONE_ZONE)
+ .song(dirkSerriesSongFixture())
+ .build(),
+ Broadcast.builder()
+ .time(Instant.parse("2021-01-01T13:15:00.00Z"))
+ .channel(DRONE_ZONE)
+ .song(igneousFlameSongFixture())
+ .build(),
+ Broadcast.builder()
+ .time(Instant.parse("2021-01-01T13:20:00.00Z"))
+ .channel(DRONE_ZONE)
+ .song(breakSongFixture())
+ .build()));
+ var business = new RecentBroadcastBusiness(repository, mapper);
+ var expected = """
+ [2021-01-01 14:20:00 @ Drone Zone] Break / Station ID
+ [2021-01-01 14:15:00 @ Drone Zone] Igneous Flame - Incandescent Arc
+ [2021-01-01 14:00:00 @ Drone Zone] Dirk Serries' Microphonics - VI"""
+ .replaceAll("[\\r\\n]", "");
- // When
- String actualOutput = tapSystemOut(() -> business.displayRecentBroadcasts(DRONE_ZONE.getPublicName())).trim();
+ // When
+ Statement statement = () -> business.handleRecentBroadcasts(DISPLAY, DRONE_ZONE);
+ String actual = tapSystemOut(statement).replaceAll("[\\r\\n]", "");
- // Then
- assertThat(actualOutput).isEqualTo(expectedOutput);
- }
+ // Then
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ void should_display_an_informative_message_if_broadcast_retrieval_fails() throws Exception {
+ // Given
+ BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class);
+ given(repository.getRecentBroadcasts(any())).willThrow(IOException.class);
+ var business = new RecentBroadcastBusiness(repository, mapper);
+ var expected = "Unable to get recent broadcasts from SomaFM at this time. Please try again later.";
+
+ try (LogCaptor logCaptor = LogCaptor.forClass(RecentBroadcastBusiness.class)) {
+ // When
+ business.handleRecentBroadcasts(DISPLAY, DRONE_ZONE);
+
+ // Then
+ assertThat(logCaptor.getErrorLogs()).containsExactly(expected);
+ }
+ }
+
+ @Test
+ void should_display_an_informative_message_if_parsing_fails() throws Exception {
+ // Given
+ BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class);
+ var expected = "Unable to parse SomaFM recent broadcasts page.";
+ given(repository.getRecentBroadcasts(any())).willThrow(new SomaFmHtmlParsingException(expected));
+ var business = new RecentBroadcastBusiness(repository, mapper);
- @Test
- void should_display_an_informative_message_if_parsing_fails() throws Exception {
- // Given
- BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class);
- var error = "Parsing error";
- given(repository.getRecentBroadcasts(any())).willThrow(new SomaFmHtmlParsingException(error));
- var business = new RecentBroadcastBusiness(repository, mapper);
+ try (LogCaptor logCaptor = LogCaptor.forClass(RecentBroadcastBusiness.class)) {
+ // When
+ business.handleRecentBroadcasts(DISPLAY, DRONE_ZONE);
- // When
- String actualOutput = tapSystemOut(() -> business.displayRecentBroadcasts(DRONE_ZONE.getPublicName())).trim();
+ // Then
+ assertThat(logCaptor.getErrorLogs()).containsExactly(expected);
+ }
+ }
- // Then
- assertThat(actualOutput).isEqualTo(error);
}
}
diff --git a/src/test/java/net/lecigne/somafm/cli/CLITest.java b/src/test/java/net/lecigne/somafm/cli/CLITest.java
new file mode 100644
index 0000000..e365adf
--- /dev/null
+++ b/src/test/java/net/lecigne/somafm/cli/CLITest.java
@@ -0,0 +1,73 @@
+package net.lecigne.somafm.cli;
+
+import static net.lecigne.somafm.business.BusinessAction.DISPLAY;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+
+import net.lecigne.somafm.business.BusinessAction;
+import net.lecigne.somafm.business.RecentBroadcastBusiness;
+import nl.altindag.log.LogCaptor;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+@DisplayName("The CLI")
+class CLITest {
+
+ private RecentBroadcastBusiness business;
+ private CLI cli;
+
+ @BeforeEach
+ void setUp() {
+ business = Mockito.mock(RecentBroadcastBusiness.class);
+ cli = new CLI(business);
+ }
+
+ @Test
+ void should_log_error_if_wrong_number_of_args() {
+ // Given
+ var args = new String[]{"one arg"};
+ var expected = "You must enter two arguments - action and channel.";
+
+ try (LogCaptor logCaptor = LogCaptor.forClass(CLI.class)) {
+ // When
+ cli.run(args);
+
+ // Then
+ assertThat(logCaptor.getErrorLogs()).contains(expected);
+ }
+ }
+
+ @Test
+ void should_use_display_action_if_wrong_action() {
+ // Given
+ var args = new String[]{"foobar", "Drone Zone"};
+ ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(BusinessAction.class);
+
+ // When
+ cli.run(args);
+
+ // Then
+ Mockito.verify(business).handleRecentBroadcasts(argumentCaptor.capture(), any());
+ BusinessAction action = argumentCaptor.getValue();
+ assertThat(action).isEqualTo(DISPLAY);
+ }
+
+ @Test
+ void should_log_if_unknown_channel() {
+ // Given
+ var args = new String[]{"display", "Foobar FM"};
+ var expected = "Unknown channel: Foobar FM";
+
+ try (LogCaptor logCaptor = LogCaptor.forClass(CLI.class)) {
+ // When
+ cli.run(args);
+
+ // Then
+ assertThat(logCaptor.getErrorLogs()).contains(expected);
+ }
+ }
+
+}
diff --git a/src/test/java/net/lecigne/somafm/fixtures/TestRepository.java b/src/test/java/net/lecigne/somafm/fixtures/TestRepository.java
new file mode 100644
index 0000000..d74485d
--- /dev/null
+++ b/src/test/java/net/lecigne/somafm/fixtures/TestRepository.java
@@ -0,0 +1,84 @@
+package net.lecigne.somafm.fixtures;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import lombok.RequiredArgsConstructor;
+import net.lecigne.somafm.model.Broadcast;
+import net.lecigne.somafm.model.Channel;
+import net.lecigne.somafm.model.Song;
+
+@RequiredArgsConstructor
+public class TestRepository {
+
+ private final DataSource testDataSource;
+
+ public List readAllBroadcasts() throws IOException {
+ var sql = """
+ SELECT utc_time, channel, artist, title, album FROM broadcasts
+ JOIN songs on broadcasts.song_id = songs.id
+ ORDER BY utc_time DESC;""";
+ List results = new ArrayList<>();
+ try (Connection connection = testDataSource.getConnection();
+ PreparedStatement preparedStatement = connection.prepareStatement(sql);
+ ResultSet resultSet = preparedStatement.executeQuery()) {
+ while (resultSet.next()) {
+ var time = resultSet.getTimestamp(1);
+ var channel = resultSet.getString(2);
+ var artist = resultSet.getString(3);
+ var title = resultSet.getString(4);
+ var album = resultSet.getString(5);
+ var broadcast = Broadcast.builder()
+ .time(time.toInstant())
+ .channel(Channel.valueOf(channel))
+ .song(Song.builder()
+ .artist(artist)
+ .title(title)
+ .album(album)
+ .build()).build();
+ results.add(broadcast);
+ }
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ return results;
+ }
+
+ public List readAllSongs() throws IOException {
+ var sql = "SELECT artist, title, album FROM songs";
+ List results = new ArrayList<>();
+ try (Connection connection = testDataSource.getConnection();
+ PreparedStatement preparedStatement = connection.prepareStatement(sql);
+ ResultSet resultSet = preparedStatement.executeQuery()) {
+ while (resultSet.next()) {
+ var artist = resultSet.getString(1);
+ var title = resultSet.getString(2);
+ var album = resultSet.getString(3);
+ var song = Song.builder()
+ .artist(artist)
+ .title(title)
+ .album(album)
+ .build();
+ results.add(song);
+ }
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ return results;
+ }
+
+ public void deleteAllData() throws IOException {
+ var sql = "TRUNCATE TABLE songs, broadcasts";
+ try (Connection connection = testDataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.executeUpdate();
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+
+}
diff --git a/src/test/java/net/lecigne/somafm/integration/SomaFmSongHistoryIT.java b/src/test/java/net/lecigne/somafm/integration/SomaFmSongHistoryIT.java
new file mode 100644
index 0000000..e961b31
--- /dev/null
+++ b/src/test/java/net/lecigne/somafm/integration/SomaFmSongHistoryIT.java
@@ -0,0 +1,125 @@
+package net.lecigne.somafm.integration;
+
+import static net.lecigne.somafm.SomaFmSongHistory.BROADCAST_LOCATION;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.common.io.Resources;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import net.lecigne.somafm.business.RecentBroadcastBusiness;
+import net.lecigne.somafm.cli.CLI;
+import net.lecigne.somafm.client.HtmlBroadcastsClient;
+import net.lecigne.somafm.client.HtmlBroadcastsParser;
+import net.lecigne.somafm.client.RecentBroadcastsClient;
+import net.lecigne.somafm.config.SomaFmConfig;
+import net.lecigne.somafm.fixtures.TestRepository;
+import net.lecigne.somafm.mappers.BroadcastMapper;
+import net.lecigne.somafm.mappers.DisplayedBroadcastMapper;
+import net.lecigne.somafm.model.Broadcast;
+import net.lecigne.somafm.model.Song;
+import net.lecigne.somafm.repository.BroadcastRepository;
+import net.lecigne.somafm.repository.DefaultBroadcastRepository;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.flywaydb.core.Flyway;
+import org.junit.Rule;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.PostgreSQLContainer;
+
+// TODO [persist-recent-broadcasts] Mutualize resources between this test and DefaultBroadcastRepositoryIT
+@DisplayName("The application")
+public class SomaFmSongHistoryIT {
+
+ static MockWebServer mockWebServer;
+ private static CLI CLI;
+ private static TestRepository testRepository;
+
+ @Rule
+ public static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres");
+
+ @BeforeAll
+ static void beforeAll() throws IOException {
+ // MockWebServer
+ mockWebServer = new MockWebServer();
+ mockWebServer.start();
+ mockWebServer.setDispatcher(getDispatcher());
+
+ // Persistence
+ postgres.start();
+ var hikariConfig = new HikariConfig();
+ hikariConfig.setJdbcUrl(postgres.getJdbcUrl());
+ hikariConfig.setUsername(postgres.getUsername());
+ hikariConfig.setPassword(postgres.getPassword());
+ var hikariDataSource = new HikariDataSource(hikariConfig);
+
+ Flyway.configure()
+ .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
+ .load()
+ .migrate();
+
+ // Application
+ SomaFmConfig configuration = new SomaFmConfig();
+ configuration.setSomaFmBaseUrl(mockWebServer.url("/").toString());
+ configuration.setUserAgent("UA");
+ HtmlBroadcastsClient htmlClient = HtmlBroadcastsClient.create(configuration);
+ var recentBroadcastsClient = new RecentBroadcastsClient(htmlClient, new HtmlBroadcastsParser());
+ Clock clock = Clock.fixed(Instant.parse("2021-01-01T13:00:00.00Z"), ZoneId.of("Europe/Paris"));
+ BroadcastRepository repository = new DefaultBroadcastRepository(recentBroadcastsClient, new BroadcastMapper(clock),
+ hikariDataSource);
+ var business = new RecentBroadcastBusiness(repository, new DisplayedBroadcastMapper(BROADCAST_LOCATION));
+ testRepository = new TestRepository(hikariDataSource);
+ CLI = new CLI(business);
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ testRepository.deleteAllData();
+ }
+
+ @AfterAll
+ static void afterAll() throws IOException {
+ mockWebServer.shutdown();
+ }
+
+ @Test
+ void should_get_recent_broadcasts_and_persist_them_with_no_song_duplicates() throws IOException {
+ // Given
+ String[] args = {"save", "Drone Zone"};
+
+ // When
+ CLI.run(args);
+
+ // Then
+ List broadcasts = testRepository.readAllBroadcasts();
+ List songs = testRepository.readAllSongs();
+ assertThat(broadcasts).hasSize(20);
+ assertThat(songs).hasSize(19);
+ }
+
+ private static Dispatcher getDispatcher() throws IOException {
+ URL url = Resources.getResource("data/dronezone_with_one_duplicate.html");
+ String html = Resources.toString(url, StandardCharsets.UTF_8);
+ return new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest recordedRequest) {
+ return new MockResponse()
+ .setResponseCode(200)
+ .setBody(html);
+ }
+ };
+ }
+
+}
diff --git a/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java b/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java
index db0d78d..af25eb6 100644
--- a/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java
+++ b/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java
@@ -1,10 +1,13 @@
package net.lecigne.somafm.repository;
import static net.lecigne.somafm.fixtures.TestFixtures.dirkSerriesSongFixture;
+import static net.lecigne.somafm.fixtures.TestFixtures.igneousFlameSongFixture;
import static net.lecigne.somafm.model.Channel.DRONE_ZONE;
import static org.assertj.core.api.Assertions.assertThat;
import com.google.common.io.Resources;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
@@ -12,40 +15,75 @@
import java.time.Instant;
import java.time.ZoneId;
import java.util.Comparator;
+import java.util.List;
import java.util.Set;
import net.lecigne.somafm.client.HtmlBroadcastsClient;
import net.lecigne.somafm.client.HtmlBroadcastsParser;
import net.lecigne.somafm.client.RecentBroadcastsClient;
-import net.lecigne.somafm.config.Configuration;
+import net.lecigne.somafm.config.SomaFmConfig;
+import net.lecigne.somafm.fixtures.TestRepository;
import net.lecigne.somafm.mappers.BroadcastMapper;
import net.lecigne.somafm.model.Broadcast;
+import net.lecigne.somafm.model.Song;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
+import org.flywaydb.core.Flyway;
+import org.junit.Rule;
import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
@DisplayName("The default broadcast repository")
+@Testcontainers
class DefaultBroadcastRepositoryIT {
static MockWebServer mockWebServer;
private static BroadcastRepository repository;
+ private static TestRepository testRepository;
+
+ @Rule
+ public static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres");
@BeforeAll
static void beforeAll() throws IOException {
+ // MockWebServer
mockWebServer = new MockWebServer();
mockWebServer.start();
mockWebServer.setDispatcher(getDispatcher());
- Configuration configuration = new Configuration();
+
+ // Persistence
+ postgres.start();
+ var hikariConfig = new HikariConfig();
+ hikariConfig.setJdbcUrl(postgres.getJdbcUrl());
+ hikariConfig.setUsername(postgres.getUsername());
+ hikariConfig.setPassword(postgres.getPassword());
+ var hikariDataSource = new HikariDataSource(hikariConfig);
+
+ Flyway.configure()
+ .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
+ .load()
+ .migrate();
+
+ // Application
+ SomaFmConfig configuration = new SomaFmConfig();
configuration.setSomaFmBaseUrl(mockWebServer.url("/").toString());
configuration.setUserAgent("UA");
HtmlBroadcastsClient htmlClient = HtmlBroadcastsClient.create(configuration);
var recentBroadcastsClient = new RecentBroadcastsClient(htmlClient, new HtmlBroadcastsParser());
Clock clock = Clock.fixed(Instant.parse("2021-01-01T13:00:00.00Z"), ZoneId.of("Europe/Paris"));
- repository = new DefaultBroadcastRepository(recentBroadcastsClient, new BroadcastMapper(clock));
+ repository = new DefaultBroadcastRepository(recentBroadcastsClient, new BroadcastMapper(clock), hikariDataSource);
+ testRepository = new TestRepository(hikariDataSource);
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ testRepository.deleteAllData();
}
@AfterAll
@@ -74,6 +112,87 @@ void should_get_most_recent_broadcasts() throws IOException {
assertThat(mostRecentBroadcast).usingRecursiveComparison().isEqualTo(expectedMostRecentBroadcast);
}
+ @Test
+ void should_persist_broadcasts() throws IOException {
+ // Given
+ Broadcast broadcast1 = Broadcast.builder()
+ .time(Instant.parse("2021-01-01T11:36:43.123Z"))
+ .channel(DRONE_ZONE)
+ .song(dirkSerriesSongFixture())
+ .build();
+ Broadcast broadcast2 = Broadcast.builder()
+ .time(Instant.parse("2021-01-01T11:45:37.967Z"))
+ .channel(DRONE_ZONE)
+ .song(igneousFlameSongFixture())
+ .build();
+
+ // When
+ repository.updateBroadcasts(Set.of(broadcast1, broadcast2));
+
+ // Then
+ List broadcasts = testRepository.readAllBroadcasts();
+ assertThat(broadcasts)
+ .hasSize(2)
+ .usingRecursiveFieldByFieldElementComparator()
+ .containsExactlyInAnyOrder(broadcast1, broadcast2);
+ }
+
+ @Test
+ void should_not_insert_same_broadcast_twice_and_ignore_failure() throws IOException {
+ // Given
+ Broadcast broadcast1 = Broadcast.builder()
+ .time(Instant.parse("2021-01-01T11:36:43.123Z"))
+ .channel(DRONE_ZONE)
+ .song(dirkSerriesSongFixture())
+ .build();
+ Broadcast broadcast2 = Broadcast.builder()
+ .time(Instant.parse("2021-01-01T11:36:43.123Z"))
+ .channel(DRONE_ZONE)
+ .song(dirkSerriesSongFixture())
+ .build();
+
+ // When
+ repository.updateBroadcasts(Set.of(broadcast1, broadcast2));
+
+ // Then
+ List broadcasts = testRepository.readAllBroadcasts();
+ assertThat(broadcasts)
+ .hasSize(1)
+ .usingRecursiveFieldByFieldElementComparator()
+ .containsExactlyInAnyOrder(broadcast1);
+ }
+
+ @Test
+ void should_not_insert_same_song_twice() throws IOException {
+ // Given - 2 broadcasts of the same song
+ Broadcast broadcast1 = Broadcast.builder()
+ .time(Instant.parse("2021-01-01T11:36:43.123Z"))
+ .channel(DRONE_ZONE)
+ .song(dirkSerriesSongFixture())
+ .build();
+ Broadcast broadcast2 = Broadcast.builder()
+ .time(Instant.parse("2021-01-02T14:50:50.420Z"))
+ .channel(DRONE_ZONE)
+ .song(dirkSerriesSongFixture())
+ .build();
+
+ // When
+ repository.updateBroadcasts(Set.of(broadcast1, broadcast2));
+
+ // Then
+ List broadcasts = testRepository.readAllBroadcasts();
+ assertThat(broadcasts)
+ .hasSize(2)
+ .usingRecursiveFieldByFieldElementComparator()
+ .containsExactlyInAnyOrder(broadcast1, broadcast2);
+
+ List songs = testRepository.readAllSongs();
+ assertThat(songs)
+ .hasSize(1)
+ .usingRecursiveFieldByFieldElementComparator()
+ .containsExactlyInAnyOrder(dirkSerriesSongFixture());
+ }
+
private static Dispatcher getDispatcher() throws IOException {
URL url = Resources.getResource("data/dronezone.html");
String html = Resources.toString(url, StandardCharsets.UTF_8);
diff --git a/src/test/resources/data/dronezone_with_one_duplicate.html b/src/test/resources/data/dronezone_with_one_duplicate.html
new file mode 100644
index 0000000..72405b9
--- /dev/null
+++ b/src/test/resources/data/dronezone_with_one_duplicate.html
@@ -0,0 +1,257 @@
+