diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be12330 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: Build Gradle project + +on: + push: + +jobs: + build-gradle-project: + runs-on: ubuntu-latest + steps: + - name: Checkout project sources + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'adopt' + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Run build with Gradle Wrapper + run: ./gradlew build + + - name: Run Kover + run: ./gradlew koverXmlReport + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: dobrosi/kotlin-feladat-ms + files: ./build/reports/kover/xmlReport.xml + fail_ci_if_error: false diff --git a/README.md b/README.md index 7e2589d..2284c6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![build](https://github.com/dobrosi/kotlin-feladat-ms/actions/workflows/ci.yml/badge.svg)](https://github.com/dobrosi/kotlin-feladat-ms/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/dobrosi/kotlin-feladat-ms/graph/badge.svg?token=Bv0alq4qqQ)](https://codecov.io/gh/dobrosi/kotlin-feladat-ms) # Kotlin oktatáshoz házifeladat ## Készíteni kell egy nagyon egyszerű alkalmazást / programot, az alábbi funkcionalitással diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1182c84..b9cb97d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { kotlin("jvm") version kotlinVersion kotlin("plugin.spring") version kotlinVersion id("org.springframework.boot") version "3.2.3" + id("org.jetbrains.kotlinx.kover") version "0.7.6" } group = "hu.kotlin.feladat.ms" @@ -14,9 +15,17 @@ repositories { dependencies { implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude("org.mockito:mockito-core") + } testImplementation(kotlin("test")) - testImplementation("io.mockk:mockk:1.4.1") + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("com.ninja-squad:springmockk:4.0.2") + testImplementation("com.github.stefanbirkner:system-lambda:1.2.1") } tasks.test { diff --git a/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/client/WeatherClient.kt b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/client/WeatherClient.kt new file mode 100644 index 0000000..e61819e --- /dev/null +++ b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/client/WeatherClient.kt @@ -0,0 +1,11 @@ +package hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.client + +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.dto.Forecast +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient + +@Component +class WeatherClient(@Autowired val forecast: WebClient.RequestHeadersSpec<*>) { + fun getForecast(): Forecast = forecast.retrieve().bodyToMono(Forecast::class.java).block()!! +} diff --git a/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/configuration/WeatherAppConfiguration.kt b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/configuration/WeatherAppConfiguration.kt new file mode 100644 index 0000000..1c8169a --- /dev/null +++ b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/configuration/WeatherAppConfiguration.kt @@ -0,0 +1,22 @@ +package hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.configuration + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class WeatherAppConfiguration { + @Bean + fun webClient(): WebClient = WebClient.builder().build() + + @Bean + fun uriSpec(@Autowired webClient: WebClient): WebClient.RequestHeadersUriSpec<*> = webClient.get() + + @Bean + fun forecast( + @Autowired uriSpec: WebClient.RequestHeadersUriSpec<*>, + @Value("\${forecast-url}") weatherApiUrl: String + ): WebClient.RequestHeadersSpec<*> = uriSpec.uri(weatherApiUrl) +} diff --git a/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/controller/IndexController.kt b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/controller/IndexController.kt new file mode 100644 index 0000000..2f8262b --- /dev/null +++ b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/controller/IndexController.kt @@ -0,0 +1,24 @@ +package hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.controller + +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.service.WeatherService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.servlet.ModelAndView + +@Controller +class IndexController(@Autowired val weatherService: WeatherService) { + @GetMapping("/") + fun dailyAverages(): ModelAndView = modelAndView() + .addObject("dailyAverages", weatherService.getDailyAverages()) + + @ExceptionHandler(RuntimeException::class) + fun error(): ModelAndView = modelAndView() + .addObject("dailyAverages", emptyMap()) + .addObject("error", SERVICE_UNAVAILABLE) + + private fun modelAndView() = ModelAndView("dailyAverages") +} + +const val SERVICE_UNAVAILABLE = "Service unavailable" diff --git a/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/dto/Forecast.kt b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/dto/Forecast.kt new file mode 100644 index 0000000..0279e47 --- /dev/null +++ b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/dto/Forecast.kt @@ -0,0 +1,12 @@ +package hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.dto + +import java.time.LocalDateTime + +data class Forecast( + var hourly: Hourly +) + +data class Hourly ( + var time: Iterable, + var temperature_2m: Iterable +) diff --git a/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/service/WeatherService.kt b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/service/WeatherService.kt new file mode 100644 index 0000000..c3c1047 --- /dev/null +++ b/app/src/main/kotlin/hu/vanio/kotlin/feladat/ms/service/WeatherService.kt @@ -0,0 +1,25 @@ +package hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.client.WeatherClient +import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.io.OutputStream + +@Service +class WeatherService(@Autowired val weatherClient: WeatherClient) { + @PostConstruct + fun postConstruct() { + printDailyAverages() + } + + fun printDailyAverages(out: OutputStream = System.out) = jacksonObjectMapper().writeValue(out, getDailyAverages()) + + fun getDailyAverages() = getHourly().groupBy { it.first.toLocalDate() } + .mapValues { it.value.map { pair -> pair.second }.average() } + + fun getHourly() = weatherClient.getForecast().hourly.let { + it.time.zip(it.temperature_2m) + } +} diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml new file mode 100644 index 0000000..1f546c1 --- /dev/null +++ b/app/src/main/resources/application.yaml @@ -0,0 +1,4 @@ +weather: + api: + url: https://api.open-meteo.com/v1/ +forecast-url: ${weather.api.url}/forecast?latitude=47.4984&longitude=19.0404&hourly=temperature_2m&timezone=auto \ No newline at end of file diff --git a/app/src/main/resources/templates/dailyAverages.html b/app/src/main/resources/templates/dailyAverages.html new file mode 100644 index 0000000..878cffd --- /dev/null +++ b/app/src/main/resources/templates/dailyAverages.html @@ -0,0 +1,28 @@ + + + + Daily Temperature Averages + + + + +

The weather service is currently unavailable.

+ + + + + + + + + +
DateDaily temperature average (°C)
+ + \ No newline at end of file diff --git a/app/src/test/kotlin/WeatherAppTest.kt b/app/src/test/kotlin/WeatherAppTest.kt index a81a55a..5fc6b3f 100644 --- a/app/src/test/kotlin/WeatherAppTest.kt +++ b/app/src/test/kotlin/WeatherAppTest.kt @@ -1,11 +1,18 @@ package hu.vanio.kotlin.feladat.ms -import kotlin.test.Test +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.service.WeatherService +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import kotlin.test.assertNotNull +@SpringBootTest class WeatherAppTest { + @Autowired + lateinit var weatherService: WeatherService - @Test fun `sikeres lekerdezes`() { - TODO() + @Test + fun testContext() { + assertNotNull(weatherService) } - } \ No newline at end of file diff --git a/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/client/WeatherClientTest.kt b/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/client/WeatherClientTest.kt new file mode 100644 index 0000000..7ccb279 --- /dev/null +++ b/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/client/WeatherClientTest.kt @@ -0,0 +1,39 @@ +package hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.client + +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.dto.Forecast +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import kotlin.test.assertEquals + +@ExtendWith(MockKExtension::class) +class WeatherClientTest{ + @InjectMockKs + lateinit var weatherClient: WeatherClient + + @MockK + lateinit var forecastRequest: WebClient.RequestHeadersSpec<*> + + @MockK + lateinit var responseSpec: WebClient.ResponseSpec + + @MockK + lateinit var forecastMono: Mono + + @MockK + lateinit var forecast: Forecast + + @Test + fun getForecast() { + every { forecastRequest.retrieve() } returns responseSpec + every { responseSpec.bodyToMono(Forecast::class.java) } returns forecastMono + every { forecastMono.block() } returns forecast + + assertEquals(forecast, weatherClient.getForecast()) + } +} diff --git a/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/controller/IndexControllerTest.kt b/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/controller/IndexControllerTest.kt new file mode 100644 index 0000000..c88b1c4 --- /dev/null +++ b/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/controller/IndexControllerTest.kt @@ -0,0 +1,54 @@ +package hu.vanio.kotlin.feladat.ms.controller + +import com.ninjasquad.springmockk.MockkBean +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.controller.IndexController +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.service.WeatherService +import io.mockk.every +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.time.LocalDate + +@SpringBootTest +class IndexControllerTest { + + @MockkBean + lateinit var weatherService: WeatherService + + lateinit var mockMvc: MockMvc + + @BeforeEach fun init() { + mockMvc = MockMvcBuilders.standaloneSetup(IndexController(weatherService)).build() + } + + @Test + fun `when call index page`() { + every { weatherService.getDailyAverages() } returns dailyAverages + + mockMvc + .perform(get("/")) + .andExpect(status().isOk) + .andExpect(model().attribute("dailyAverages", dailyAverages)) + } + + @Test + fun `when call index page if service unavailable`() { + every { weatherService.getDailyAverages() } throws RuntimeException() + + mockMvc + .perform(get("/")) + .andExpect(status().isOk) + .andExpect(model().attribute("dailyAverages", emptyMap())) + .andExpect(model().attribute("error", "Service unavailable")) + } + +} + +val DATE: LocalDate = LocalDate.of(2024, 2, 21) +const val VALUE = 9.87 +val dailyAverages = mapOf(DATE to VALUE) diff --git a/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/service/WeatherServiceTest.kt b/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/service/WeatherServiceTest.kt new file mode 100644 index 0000000..b9bc83f --- /dev/null +++ b/app/src/test/kotlin/hu/vanio/kotlin/feladat/ms/service/WeatherServiceTest.kt @@ -0,0 +1,111 @@ +package hu.vanio.kotlin.feladat.ms.service + +import com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemOut +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.client.WeatherClient +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.dto.Forecast +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.dto.Hourly +import hu.vanio.kotlin.feladat.ms.hu.vanio.kotlin.feladat.ms.service.WeatherService +import io.mockk.InternalPlatformDsl.toStr +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.io.ByteArrayOutputStream +import java.time.LocalDate +import java.time.LocalDateTime + +@ExtendWith(MockKExtension::class) +class WeatherServiceTest { + @InjectMockKs + lateinit var weatherService: WeatherService + + @MockK + lateinit var weatherClient: WeatherClient + + @BeforeEach + fun setUp() { + every { weatherClient.getForecast() } returns FORECAST + } + + @Test + fun postConstruct() { + assertEquals(EXPECTED_JSON, tapSystemOut { weatherService.postConstruct() }) + } + + @Test + fun printDailyAverages() { + val byteArrayOutputStream = ByteArrayOutputStream() + + weatherService.printDailyAverages(byteArrayOutputStream) + + assertEquals(EXPECTED_JSON, byteArrayOutputStream.toStr()) + } + + @Test + fun getDailyAverages() { + assertThat(weatherService.getDailyAverages()).isEqualTo( + mapOf( + LocalDate.of(2024, 5, 28) to 15.0, + LocalDate.of(2024, 5, 29) to 16.0 + ) + ) + } + + @Test + fun getHourly() { + assertThat(weatherService.getHourly()).isEqualTo( + listOf( + Pair(DATE1, VALUE1), + Pair(DATE2, VALUE2), + Pair(DATE3, VALUE3), + Pair(DATE4, VALUE4), + Pair(DATE5, VALUE5), + Pair(DATE6, VALUE6) + ) + ) + } + + @Test + fun getWeatherClient() { + assertEquals(weatherClient, weatherService.weatherClient) + } +} + +val DATE1: LocalDateTime = LocalDateTime.of(2024, 5, 28, 0, 0) +const val VALUE1: Double = 12.0 +val DATE2: LocalDateTime = LocalDateTime.of(2024, 5, 28, 12, 0) +const val VALUE2: Double = 18.0 +val DATE3: LocalDateTime = LocalDateTime.of(2024, 5, 29, 0, 0) +const val VALUE3: Double = 13.0 +val DATE4: LocalDateTime = LocalDateTime.of(2024, 5, 29, 6, 0) +const val VALUE4: Double = 15.0 +val DATE5: LocalDateTime = LocalDateTime.of(2024, 5, 29, 12, 0) +const val VALUE5: Double = 20.0 +val DATE6: LocalDateTime = LocalDateTime.of(2024, 5, 29, 18, 0) +const val VALUE6: Double = 16.0 +val HOURLY = + Hourly( + listOf( + DATE1, + DATE2, + DATE3, + DATE4, + DATE5, + DATE6 + ), + listOf( + VALUE1, + VALUE2, + VALUE3, + VALUE4, + VALUE5, + VALUE6 + ) + ) +val FORECAST = Forecast(HOURLY) +const val EXPECTED_JSON = "{\"2024-05-28\":15.0,\"2024-05-29\":16.0}" diff --git a/gradlew b/gradlew old mode 100644 new mode 100755