diff --git a/build.gradle b/build.gradle index f48ae69..b5a3b83 100644 --- a/build.gradle +++ b/build.gradle @@ -53,18 +53,19 @@ dependencies { implementation("io.micronaut.kotlin:micronaut-kotlin-extension-functions") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation "nu.studer:gradle-jooq-plugin:$jooqPluginVersion" + implementation("org.postgresql:postgresql:${postgresVersion}") detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion" runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("ch.qos.logback.contrib:logback-json-classic:${logbackJsonVersion}") runtimeOnly("ch.qos.logback.contrib:logback-jackson:${logbackJsonVersion}") runtimeOnly("net.logstash.logback:logstash-logback-encoder:${logstashEncoderVersion}") runtimeOnly("org.codehaus.janino:janino:${janinoVersion}") - runtimeOnly("org.postgresql:postgresql:${postgresVersion}") kaptTest(platform("io.micronaut:micronaut-bom:$micronautVersion")) kaptTest("io.micronaut:micronaut-inject-java") testImplementation(platform("io.micronaut:micronaut-bom:$micronautVersion")) testImplementation("io.micronaut.test:micronaut-test-kotest") testImplementation("io.kotest:kotest-runner-junit5-jvm") + testImplementation("io.kotest:kotest-assertions-core:${kotestVersion}") testImplementation("io.mockk:mockk:${mockkVersion}") testImplementation("org.testcontainers:postgresql:${testContainersVersion}") jooqRuntime("org.postgresql:postgresql:${postgresVersion}") diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..fceb076 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + target: 90% + threshold: 0% + patch: + default: + target: 80% + threshold: 0% diff --git a/gradle.properties b/gradle.properties index 515c0b7..7e77065 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ micronautVersion=2.0.1 kotlinVersion=1.3.72 detektVersion=1.10.0 jooqPluginVersion=4.2 -jibVersion=2.4.0 +jibVersion=2.5.0 shadowVersion=6.0.0 arrowDataVersion=0.10.5 kotlinCoroutinesVersion=1.3.8 @@ -13,3 +13,4 @@ palantirGitVersion=0.12.3 logbackJsonVersion=0.1.5 logstashEncoderVersion=6.4 janinoVersion=3.1.2 +kotestVersion=4.0.3 diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultCatalog.java b/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultCatalog.java index f6d7e10..e282655 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultCatalog.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultCatalog.java @@ -17,7 +17,7 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class DefaultCatalog extends CatalogImpl { - private static final long serialVersionUID = 232955134; + private static final long serialVersionUID = 1946704898; /** * The reference instance of DEFAULT_CATALOG diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultSchema.java b/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultSchema.java index 8d511d0..085edee 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultSchema.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/DefaultSchema.java @@ -23,7 +23,7 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class DefaultSchema extends SchemaImpl { - private static final long serialVersionUID = 255525230; + private static final long serialVersionUID = 63509414; /** * The reference instance of DEFAULT_SCHEMA diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/Keys.java b/src/jooq/java/com/kuvaszuptime/kuvasz/Keys.java index cbc28fd..0aa7a8e 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/Keys.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/Keys.java @@ -19,7 +19,7 @@ /** - * A class modelling foreign key relationships and constraints of tables of + * A class modelling foreign key relationships and constraints of tables of * the schema. */ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) @@ -39,6 +39,7 @@ public class Keys { public static final UniqueKey LATENCY_LOG_PKEY = UniqueKeys0.LATENCY_LOG_PKEY; public static final UniqueKey MONITOR_PKEY = UniqueKeys0.MONITOR_PKEY; + public static final UniqueKey UNIQUE_MONITOR_NAME = UniqueKeys0.UNIQUE_MONITOR_NAME; public static final UniqueKey UPTIME_EVENT_PKEY = UniqueKeys0.UPTIME_EVENT_PKEY; public static final UniqueKey UPTIME_EVENT_KEY = UniqueKeys0.UPTIME_EVENT_KEY; @@ -62,6 +63,7 @@ private static class Identities0 { private static class UniqueKeys0 { public static final UniqueKey LATENCY_LOG_PKEY = Internal.createUniqueKey(LatencyLog.LATENCY_LOG, "latency_log_pkey", new TableField[] { LatencyLog.LATENCY_LOG.ID }, true); public static final UniqueKey MONITOR_PKEY = Internal.createUniqueKey(Monitor.MONITOR, "monitor_pkey", new TableField[] { Monitor.MONITOR.ID }, true); + public static final UniqueKey UNIQUE_MONITOR_NAME = Internal.createUniqueKey(Monitor.MONITOR, "unique_monitor_name", new TableField[] { Monitor.MONITOR.NAME }, true); public static final UniqueKey UPTIME_EVENT_PKEY = Internal.createUniqueKey(UptimeEvent.UPTIME_EVENT, "uptime_event_pkey", new TableField[] { UptimeEvent.UPTIME_EVENT.ID }, true); public static final UniqueKey UPTIME_EVENT_KEY = Internal.createUniqueKey(UptimeEvent.UPTIME_EVENT, "uptime_event_key", new TableField[] { UptimeEvent.UPTIME_EVENT.MONITOR_ID, UptimeEvent.UPTIME_EVENT.STATUS, UptimeEvent.UPTIME_EVENT.ENDED_AT }, true); } diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/Sequences.java b/src/jooq/java/com/kuvaszuptime/kuvasz/Sequences.java index 6c673fd..45f0432 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/Sequences.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/Sequences.java @@ -9,7 +9,7 @@ /** - * Convenience access to all sequences in + * Convenience access to all sequences in */ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class Sequences { diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/Tables.java b/src/jooq/java/com/kuvaszuptime/kuvasz/Tables.java index e5a87ab..6b112f5 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/Tables.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/Tables.java @@ -10,7 +10,7 @@ /** - * Convenience access to all tables in + * Convenience access to all tables in */ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class Tables { diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/LatencyLog.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/LatencyLog.java index c09d304..24758ad 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/LatencyLog.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/LatencyLog.java @@ -35,7 +35,7 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class LatencyLog extends TableImpl { - private static final long serialVersionUID = 2080318128; + private static final long serialVersionUID = 2052986796; /** * The reference instance of latency_log diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/Monitor.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/Monitor.java index 22c25ca..7adfad5 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/Monitor.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/Monitor.java @@ -33,7 +33,7 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class Monitor extends TableImpl { - private static final long serialVersionUID = -1852813365; + private static final long serialVersionUID = -1808562877; /** * The reference instance of monitor @@ -133,7 +133,7 @@ public UniqueKey getPrimaryKey() { @Override public List> getKeys() { - return Arrays.>asList(Keys.MONITOR_PKEY); + return Arrays.>asList(Keys.MONITOR_PKEY, Keys.UNIQUE_MONITOR_NAME); } @Override diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/UptimeEvent.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/UptimeEvent.java index 1318fb3..decf274 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/UptimeEvent.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/UptimeEvent.java @@ -36,7 +36,7 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class UptimeEvent extends TableImpl { - private static final long serialVersionUID = -2004053888; + private static final long serialVersionUID = 497612164; /** * The reference instance of uptime_event diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/daos/MonitorDao.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/daos/MonitorDao.java index 19aa15d..29936a5 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/daos/MonitorDao.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/daos/MonitorDao.java @@ -75,6 +75,13 @@ public List fetchByName(String... values) { return fetch(Monitor.MONITOR.NAME, values); } + /** + * Fetch a unique record that has name = value + */ + public MonitorPojo fetchOneByName(String value) { + return fetchOne(Monitor.MONITOR.NAME, value); + } + /** * Fetch records that have url BETWEEN lowerInclusive AND upperInclusive */ diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/LatencyLogPojo.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/LatencyLogPojo.java index 57787c6..7d5361d 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/LatencyLogPojo.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/LatencyLogPojo.java @@ -30,7 +30,7 @@ }) public class LatencyLogPojo implements Serializable { - private static final long serialVersionUID = -204244894; + private static final long serialVersionUID = -213764250; private Integer id; private Integer monitorId; diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/MonitorPojo.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/MonitorPojo.java index 5e9af31..2fb4cf4 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/MonitorPojo.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/MonitorPojo.java @@ -24,11 +24,12 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) @Entity @Table(name = "monitor", uniqueConstraints = { - @UniqueConstraint(name = "monitor_pkey", columnNames = {"id"}) + @UniqueConstraint(name = "monitor_pkey", columnNames = {"id"}), + @UniqueConstraint(name = "unique_monitor_name", columnNames = {"name"}) }) public class MonitorPojo implements Serializable { - private static final long serialVersionUID = 763841695; + private static final long serialVersionUID = -1292240686; private Integer id; private String name; diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/UptimeEventPojo.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/UptimeEventPojo.java index 3c021c5..f2d1f60 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/UptimeEventPojo.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/pojos/UptimeEventPojo.java @@ -34,7 +34,7 @@ }) public class UptimeEventPojo implements Serializable { - private static final long serialVersionUID = -530417720; + private static final long serialVersionUID = -692998392; private Integer id; private Integer monitorId; diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/LatencyLogRecord.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/LatencyLogRecord.java index e40c158..5d00b08 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/LatencyLogRecord.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/LatencyLogRecord.java @@ -37,7 +37,7 @@ }) public class LatencyLogRecord extends UpdatableRecordImpl implements Record4 { - private static final long serialVersionUID = 481973074; + private static final long serialVersionUID = 1412652498; /** * Setter for latency_log.id. diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/MonitorRecord.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/MonitorRecord.java index fd2b0a4..c035b7f 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/MonitorRecord.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/MonitorRecord.java @@ -31,11 +31,12 @@ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) @Entity @Table(name = "monitor", uniqueConstraints = { - @UniqueConstraint(name = "monitor_pkey", columnNames = {"id"}) + @UniqueConstraint(name = "monitor_pkey", columnNames = {"id"}), + @UniqueConstraint(name = "unique_monitor_name", columnNames = {"name"}) }) public class MonitorRecord extends UpdatableRecordImpl implements Record7 { - private static final long serialVersionUID = 1371821158; + private static final long serialVersionUID = 1383018433; /** * Setter for monitor.id. diff --git a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/UptimeEventRecord.java b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/UptimeEventRecord.java index b6f8752..c59b00e 100644 --- a/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/UptimeEventRecord.java +++ b/src/jooq/java/com/kuvaszuptime/kuvasz/tables/records/UptimeEventRecord.java @@ -40,7 +40,7 @@ }) public class UptimeEventRecord extends UpdatableRecordImpl implements Record6 { - private static final long serialVersionUID = 629056969; + private static final long serialVersionUID = 187938053; /** * Setter for uptime_event.id. diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/Controller.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/Controller.kt deleted file mode 100644 index cba9119..0000000 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/Controller.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.kuvaszuptime.kuvasz - -import io.micronaut.http.HttpResponse -import io.micronaut.http.annotation.Controller -import io.micronaut.http.annotation.Get - -@Controller("/") -class Controller { - @Get("/hello") - fun get(): HttpResponse = HttpResponse.ok() -} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/GlobalErrorHandler.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/GlobalErrorHandler.kt new file mode 100644 index 0000000..b3b3f68 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/GlobalErrorHandler.kt @@ -0,0 +1,66 @@ +package com.kuvaszuptime.kuvasz.controllers + +import com.fasterxml.jackson.core.JsonParseException +import com.kuvaszuptime.kuvasz.models.DuplicationError +import com.kuvaszuptime.kuvasz.models.MonitorNotFoundError +import com.kuvaszuptime.kuvasz.models.PersistenceError +import com.kuvaszuptime.kuvasz.models.SchedulingError +import com.kuvaszuptime.kuvasz.models.ServiceError +import io.micronaut.core.convert.exceptions.ConversionErrorException +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Error +import javax.validation.ValidationException + +@Controller +class GlobalErrorHandler { + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun jsonError(request: HttpRequest<*>, monitorNotFoundError: MonitorNotFoundError): HttpResponse { + val error = ServiceError(monitorNotFoundError.message) + return HttpResponse.notFound(error) + } + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun jsonError(request: HttpRequest<*>, duplicationError: DuplicationError): HttpResponse { + val error = ServiceError(duplicationError.message) + return HttpResponse.status(HttpStatus.CONFLICT).body(error) + } + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun error(request: HttpRequest<*>, throwable: ValidationException): HttpResponse = + HttpResponse.badRequest(ServiceError(throwable.message)) + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun error(request: HttpRequest<*>, throwable: ConversionErrorException): HttpResponse { + val message = "Failed to convert argument: ${throwable.argument}" + return HttpResponse.badRequest(ServiceError(message)) + } + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun error(request: HttpRequest<*>, throwable: JsonParseException): HttpResponse { + val message = "Can't parse the JSON in the payload" + return HttpResponse.badRequest(ServiceError(message)) + } + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun jsonError(request: HttpRequest<*>, persistencyError: PersistenceError): HttpResponse { + val error = ServiceError(persistencyError.message) + return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error) + } + + @Suppress("UNUSED_PARAMETER") + @Error(global = true) + fun jsonError(request: HttpRequest<*>, schedulingError: SchedulingError): HttpResponse { + val error = ServiceError(schedulingError.message) + return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error) + } +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorController.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorController.kt new file mode 100644 index 0000000..acaee83 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorController.kt @@ -0,0 +1,104 @@ +package com.kuvaszuptime.kuvasz.controllers + +import com.kuvaszuptime.kuvasz.models.MonitorNotFoundError +import com.kuvaszuptime.kuvasz.models.ServiceError +import com.kuvaszuptime.kuvasz.models.dto.MonitorCreateDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorDetailsDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorUpdateDto +import com.kuvaszuptime.kuvasz.services.MonitorCrudService +import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.QueryValue +import io.micronaut.http.annotation.Status +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import javax.inject.Inject + +@Controller("/monitor", produces = [MediaType.APPLICATION_JSON]) +@Tag(name = "Monitor operations") +class MonitorController @Inject constructor( + private val monitorCrudService: MonitorCrudService +) : MonitorOperations { + + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "Successful query", + content = [Content(array = ArraySchema(schema = Schema(implementation = MonitorDetailsDto::class)))] + ) + ) + override fun getMonitors(@QueryValue enabledOnly: Boolean?): List = + monitorCrudService.getMonitorDetails(enabledOnly ?: false) + + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "Successful query", + content = [Content(schema = Schema(implementation = MonitorDetailsDto::class))] + ), + ApiResponse( + responseCode = "404", + description = "Not found", + content = [Content(schema = Schema(implementation = ServiceError::class))] + ) + ) + override fun getMonitor(monitorId: Int): MonitorDetailsDto = + monitorCrudService.getMonitorDetails(monitorId).fold( + { throw MonitorNotFoundError(monitorId) }, + { it } + ) + + @Status(HttpStatus.CREATED) + @ApiResponses( + ApiResponse( + responseCode = "201", + description = "Successful creation", + content = [Content(schema = Schema(implementation = MonitorPojo::class))] + ), + ApiResponse( + responseCode = "400", + description = "Bad request", + content = [Content(schema = Schema(implementation = ServiceError::class))] + ) + ) + override fun createMonitor(monitor: MonitorCreateDto): MonitorPojo = monitorCrudService.createMonitor(monitor) + + @Status(HttpStatus.NO_CONTENT) + @ApiResponses( + ApiResponse( + responseCode = "204", + description = "Successful deletion" + ), + ApiResponse( + responseCode = "404", + description = "Not found", + content = [Content(schema = Schema(implementation = ServiceError::class))] + ) + ) + override fun deleteMonitor(monitorId: Int) = monitorCrudService.deleteMonitorById(monitorId) + + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "Successful update" + ), + ApiResponse( + responseCode = "400", + description = "Bad request", + content = [Content(schema = Schema(implementation = ServiceError::class))] + ), + ApiResponse( + responseCode = "404", + description = "Not found", + content = [Content(schema = Schema(implementation = ServiceError::class))] + ) + ) + override fun updateMonitor(monitorId: Int, monitorUpdateDto: MonitorUpdateDto): MonitorPojo = + monitorCrudService.updateMonitor(monitorId, monitorUpdateDto) +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorOperations.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorOperations.kt new file mode 100644 index 0000000..0fee295 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorOperations.kt @@ -0,0 +1,51 @@ +package com.kuvaszuptime.kuvasz.controllers + +import com.kuvaszuptime.kuvasz.models.dto.MonitorCreateDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorDetailsDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorUpdateDto +import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Delete +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Patch +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.QueryValue +import io.micronaut.scheduling.TaskExecutors +import io.micronaut.scheduling.annotation.ExecuteOn +import io.micronaut.validation.Validated +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import javax.validation.Valid + +@Validated +interface MonitorOperations { + + @Operation(summary = "Returns all monitors") + @Get("/") + @ExecuteOn(TaskExecutors.IO) + fun getMonitors( + @QueryValue + @Parameter(required = false) + enabledOnly: Boolean? + ): List + + @Operation(summary = "Returns a monitor's details") + @Get("/{monitorId}") + @ExecuteOn(TaskExecutors.IO) + fun getMonitor(monitorId: Int): MonitorDetailsDto + + @Operation(summary = "Creates a monitor") + @Post("/") + @ExecuteOn(TaskExecutors.IO) + fun createMonitor(@Valid @Body monitor: MonitorCreateDto): MonitorPojo + + @Operation(summary = "Deletes a monitor by ID") + @Delete("/{monitorId}") + @ExecuteOn(TaskExecutors.IO) + fun deleteMonitor(monitorId: Int) + + @Operation(summary = "Updates a monitor by ID") + @Patch("/{monitorId}") + @ExecuteOn(TaskExecutors.IO) + fun updateMonitor(monitorId: Int, @Valid @Body monitorUpdateDto: MonitorUpdateDto): MonitorPojo +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Error.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Error.kt new file mode 100644 index 0000000..d8312bd --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Error.kt @@ -0,0 +1,29 @@ +package com.kuvaszuptime.kuvasz.models + +import io.micronaut.core.annotation.Introspected + +@Introspected +data class ServiceError( + val message: String? = "Something bad happened :(" +) + +class MonitorNotFoundError( + private val monitorId: Int, + override val message: String? = "There is no monitor with ID: $monitorId" +) : Throwable() + +open class PersistenceError( + override val message: String? = "Something bad happened in the database :(" +) : Throwable() + +open class DuplicationError( + override val message: String? = "The given resource already exists" +) : PersistenceError() + +class MonitorDuplicatedError( + override val message: String? = "There is already a monitor with the given name" +) : DuplicationError() + +class SchedulingError( + override val message: String? = "Scheduling checks for the monitor did not succeed" +) : Throwable() diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorCreateDto.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorCreateDto.kt new file mode 100644 index 0000000..4a91434 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorCreateDto.kt @@ -0,0 +1,29 @@ +package com.kuvaszuptime.kuvasz.models.dto + +import com.kuvaszuptime.kuvasz.models.dto.Validation.MIN_UPTIME_CHECK_INTERVAL +import com.kuvaszuptime.kuvasz.models.dto.Validation.URI_REGEX +import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import io.micronaut.core.annotation.Introspected +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotNull +import javax.validation.constraints.Pattern + +@Introspected +data class MonitorCreateDto( + @get:NotBlank + val name: String, + @get:NotNull + @get:Pattern(regexp = URI_REGEX) + val url: String, + @get:NotNull + @get:Min(MIN_UPTIME_CHECK_INTERVAL) + val uptimeCheckInterval: Int, + val enabled: Boolean? = true +) { + fun toMonitorPojo(): MonitorPojo = MonitorPojo() + .setName(name) + .setUrl(url) + .setEnabled(enabled) + .setUptimeCheckInterval(uptimeCheckInterval) +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorDetailsDto.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorDetailsDto.kt new file mode 100644 index 0000000..edb7d02 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorDetailsDto.kt @@ -0,0 +1,21 @@ +package com.kuvaszuptime.kuvasz.models.dto + +import com.kuvaszuptime.kuvasz.enums.UptimeStatus +import io.micronaut.core.annotation.Introspected +import java.net.URI +import java.time.OffsetDateTime + +@Introspected +data class MonitorDetailsDto( + val id: Int, + val name: String, + val url: URI, + val uptimeCheckInterval: Int, + val enabled: Boolean, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime?, + val uptimeStatus: UptimeStatus?, + val uptimeStatusStartedAt: OffsetDateTime?, + val uptimeError: String?, + val averageLatencyInMs: Int? +) diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorUpdateDto.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorUpdateDto.kt new file mode 100644 index 0000000..d533fa3 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/MonitorUpdateDto.kt @@ -0,0 +1,17 @@ +package com.kuvaszuptime.kuvasz.models.dto + +import com.kuvaszuptime.kuvasz.models.dto.Validation.MIN_UPTIME_CHECK_INTERVAL +import com.kuvaszuptime.kuvasz.models.dto.Validation.URI_REGEX +import io.micronaut.core.annotation.Introspected +import javax.validation.constraints.Min +import javax.validation.constraints.Pattern + +@Introspected +data class MonitorUpdateDto( + val name: String?, + @get:Pattern(regexp = URI_REGEX) + val url: String?, + @get:Min(MIN_UPTIME_CHECK_INTERVAL) + val uptimeCheckInterval: Int?, + val enabled: Boolean? +) diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt new file mode 100644 index 0000000..6c5c7c5 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt @@ -0,0 +1,6 @@ +package com.kuvaszuptime.kuvasz.models.dto + +internal object Validation { + const val MIN_UPTIME_CHECK_INTERVAL = 60L + const val URI_REGEX = "^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]" +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/repositories/MonitorRepository.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/repositories/MonitorRepository.kt index 06f2b80..7025e4b 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/repositories/MonitorRepository.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/repositories/MonitorRepository.kt @@ -1,9 +1,115 @@ package com.kuvaszuptime.kuvasz.repositories +import arrow.core.Either +import arrow.core.Option +import arrow.core.toOption +import com.kuvaszuptime.kuvasz.models.DuplicationError +import com.kuvaszuptime.kuvasz.models.MonitorDuplicatedError +import com.kuvaszuptime.kuvasz.models.PersistenceError +import com.kuvaszuptime.kuvasz.models.dto.MonitorDetailsDto +import com.kuvaszuptime.kuvasz.tables.LatencyLog.LATENCY_LOG +import com.kuvaszuptime.kuvasz.tables.Monitor.MONITOR +import com.kuvaszuptime.kuvasz.tables.UptimeEvent.UPTIME_EVENT import com.kuvaszuptime.kuvasz.tables.daos.MonitorDao +import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import com.kuvaszuptime.kuvasz.util.getCurrentTimestamp +import com.kuvaszuptime.kuvasz.util.toPersistenceError import org.jooq.Configuration +import org.jooq.exception.DataAccessException +import org.jooq.impl.DSL import javax.inject.Inject import javax.inject.Singleton @Singleton -class MonitorRepository @Inject constructor(jooqConfig: Configuration) : MonitorDao(jooqConfig) +class MonitorRepository @Inject constructor(jooqConfig: Configuration) : MonitorDao(jooqConfig) { + + private val dsl = jooqConfig.dsl() + + fun getMonitorDetails(monitorId: Int): Option = + getMonitorDetailsSelect() + .where(MONITOR.ID.eq(monitorId)) + .groupBy( + MONITOR.ID, + UPTIME_EVENT.STATUS, + UPTIME_EVENT.STARTED_AT, + UPTIME_EVENT.ERROR + ) + .fetchOneInto(MonitorDetailsDto::class.java) + .toOption() + + fun getMonitorDetails(enabledOnly: Boolean): List = + getMonitorDetailsSelect() + .apply { + if (enabledOnly) { + where(MONITOR.ENABLED.isTrue) + } + } + .groupBy( + MONITOR.ID, + UPTIME_EVENT.STATUS, + UPTIME_EVENT.STARTED_AT, + UPTIME_EVENT.ERROR + ) + .fetchInto(MonitorDetailsDto::class.java) + + fun returningInsert(monitorPojo: MonitorPojo): Either = + try { + Either.right( + dsl + .insertInto(MONITOR) + .set(dsl.newRecord(MONITOR, monitorPojo)) + .returning(MONITOR.asterisk()) + .fetchOne() + .into(MonitorPojo::class.java) + ) + } catch (e: DataAccessException) { + e.handle() + } + + fun returningUpdate(updatedPojo: MonitorPojo): Either = + try { + Either.right( + dsl + .update(MONITOR) + .set(MONITOR.NAME, updatedPojo.name) + .set(MONITOR.URL, updatedPojo.url) + .set(MONITOR.UPTIME_CHECK_INTERVAL, updatedPojo.uptimeCheckInterval) + .set(MONITOR.ENABLED, updatedPojo.enabled) + .set(MONITOR.UPDATED_AT, getCurrentTimestamp()) + .where(MONITOR.ID.eq(updatedPojo.id)) + .returning(MONITOR.asterisk()) + .fetchOne() + .into(MonitorPojo::class.java) + ) + } catch (e: DataAccessException) { + e.handle() + } + + private fun getMonitorDetailsSelect() = + dsl + .select( + MONITOR.ID.`as`("id"), + MONITOR.NAME.`as`("name"), + MONITOR.URL.`as`("url"), + MONITOR.UPTIME_CHECK_INTERVAL.`as`("uptimeCheckInterval"), + MONITOR.ENABLED.`as`("enabled"), + MONITOR.CREATED_AT.`as`("createdAt"), + MONITOR.UPDATED_AT.`as`("updatedAt"), + UPTIME_EVENT.STATUS.`as`("uptimeStatus"), + UPTIME_EVENT.STARTED_AT.`as`("uptimeStatusStartedAt"), + UPTIME_EVENT.ERROR.`as`("uptimeError"), + DSL.avg(LATENCY_LOG.LATENCY).`as`("averageLatencyInMs") + ) + .from(MONITOR) + .leftJoin(UPTIME_EVENT).on(MONITOR.ID.eq(UPTIME_EVENT.MONITOR_ID).and(UPTIME_EVENT.ENDED_AT.isNull)) + .leftJoin(LATENCY_LOG).on(MONITOR.ID.eq(LATENCY_LOG.MONITOR_ID)) + + private fun DataAccessException.handle(): Either { + val persistenceError = toPersistenceError() + return Either.left( + if (persistenceError is DuplicationError) { + MonitorDuplicatedError() + } else persistenceError + ) + } +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/services/CheckScheduler.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/services/CheckScheduler.kt index a116a24..ba4d5c5 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/services/CheckScheduler.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/services/CheckScheduler.kt @@ -3,6 +3,7 @@ package com.kuvaszuptime.kuvasz.services import arrow.core.Either import com.kuvaszuptime.kuvasz.models.CheckType import com.kuvaszuptime.kuvasz.models.ScheduledCheck +import com.kuvaszuptime.kuvasz.models.SchedulingError import com.kuvaszuptime.kuvasz.repositories.MonitorRepository import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo import com.kuvaszuptime.kuvasz.util.catchBlocking @@ -37,22 +38,47 @@ class CheckScheduler @Inject constructor( fun getScheduledChecks() = scheduledChecks - fun createChecksForMonitor(monitor: MonitorPojo) { - scheduleUptimeCheck(monitor).fold( + fun createChecksForMonitor(monitor: MonitorPojo): Either = + scheduleUptimeCheck(monitor).bimap( { e -> logger.error( "Uptime check for \"${monitor.name}\" (${monitor.url}) cannot be set up: ${e.message}" ) + SchedulingError(e.message) }, { scheduledTask -> - scheduledChecks.add( + val scheduledCheck = ScheduledCheck(checkType = CheckType.UPTIME, monitorId = monitor.id, task = scheduledTask) - ) - logger.info( - "Uptime check for \"${monitor.name}\" (${monitor.url}) has been set up successfully" - ) + scheduledChecks.add(scheduledCheck) + logger.info("Uptime check for \"${monitor.name}\" (${monitor.url}) has been set up successfully") + + scheduledCheck } ) + + fun removeChecksOfMonitor(monitor: MonitorPojo) { + scheduledChecks.forEach { check -> + if (check.monitorId == monitor.id) { + check.task.cancel(false) + } + } + scheduledChecks.removeAll { it.monitorId == monitor.id } + logger.info("Uptime check for \"${monitor.name}\" (${monitor.url}) has been removed successfully") + } + + fun removeAllChecks() { + scheduledChecks.forEach { check -> + check.task.cancel(false) + } + scheduledChecks.clear() + } + + fun updateChecksForMonitor( + existingMonitor: MonitorPojo, + updatedMonitor: MonitorPojo + ): Either { + removeChecksOfMonitor(existingMonitor) + return createChecksForMonitor(updatedMonitor) } private fun scheduleUptimeCheck(monitor: MonitorPojo): Either> = diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/services/MonitorCrudService.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/services/MonitorCrudService.kt new file mode 100644 index 0000000..0c1aa69 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/services/MonitorCrudService.kt @@ -0,0 +1,78 @@ +package com.kuvaszuptime.kuvasz.services + +import arrow.core.Option +import arrow.core.toOption +import com.kuvaszuptime.kuvasz.models.MonitorNotFoundError +import com.kuvaszuptime.kuvasz.models.dto.MonitorCreateDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorDetailsDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorUpdateDto +import com.kuvaszuptime.kuvasz.repositories.MonitorRepository +import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MonitorCrudService @Inject constructor( + private val monitorRepository: MonitorRepository, + private val checkScheduler: CheckScheduler +) { + + fun getMonitorDetails(monitorId: Int): Option = monitorRepository.getMonitorDetails(monitorId) + + fun getMonitorDetails(enabledOnly: Boolean): List = + monitorRepository.getMonitorDetails(enabledOnly) + + fun createMonitor(monitorCreateDto: MonitorCreateDto): MonitorPojo = + monitorRepository.returningInsert(monitorCreateDto.toMonitorPojo()).fold( + { persistenceError -> throw persistenceError }, + { insertedMonitor -> + if (insertedMonitor.enabled) { + checkScheduler.createChecksForMonitor(insertedMonitor).mapLeft { schedulingError -> + monitorRepository.deleteById(insertedMonitor.id) + throw schedulingError + } + } + insertedMonitor + } + ) + + fun deleteMonitorById(monitorId: Int) = monitorRepository.findById(monitorId).toOption().fold( + { throw MonitorNotFoundError(monitorId) }, + { monitorPojo -> + monitorRepository.deleteById(monitorPojo.id) + checkScheduler.removeChecksOfMonitor(monitorPojo) + } + ) + + fun updateMonitor(monitorId: Int, monitorUpdateDto: MonitorUpdateDto): MonitorPojo = + monitorRepository.findById(monitorId).toOption().fold( + { throw MonitorNotFoundError(monitorId) }, + { existingMonitor -> + val updatedMonitor = MonitorPojo().apply { + id = existingMonitor.id + name = monitorUpdateDto.name ?: existingMonitor.name + url = monitorUpdateDto.url ?: existingMonitor.url + uptimeCheckInterval = monitorUpdateDto.uptimeCheckInterval ?: existingMonitor.uptimeCheckInterval + enabled = monitorUpdateDto.enabled ?: existingMonitor.enabled + } + + updatedMonitor.saveAndReschedule(existingMonitor) + } + ) + + private fun MonitorPojo.saveAndReschedule(existingMonitor: MonitorPojo): MonitorPojo = + monitorRepository.returningUpdate(this).fold( + { persistenceError -> throw persistenceError }, + { updatedMonitor -> + if (updatedMonitor.enabled) { + checkScheduler.updateChecksForMonitor(existingMonitor, updatedMonitor).fold( + { schedulingError -> throw schedulingError }, + { updatedMonitor } + ) + } else { + checkScheduler.removeChecksOfMonitor(existingMonitor) + updatedMonitor + } + } + ) +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/util/Jooq+.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/util/Jooq+.kt index e679058..4a909d4 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/util/Jooq+.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/util/Jooq+.kt @@ -2,16 +2,21 @@ package com.kuvaszuptime.kuvasz.util +import arrow.core.toOption +import com.kuvaszuptime.kuvasz.models.DuplicationError +import com.kuvaszuptime.kuvasz.models.PersistenceError import org.jooq.Configuration import org.jooq.DAO import org.jooq.TableRecord -import org.jooq.TransactionalCallable +import org.jooq.exception.DataAccessException +import org.postgresql.util.PSQLException fun , P, T> DAO.transaction(block: () -> Unit) { configuration().dsl().transaction { _: Configuration -> block() } } -fun , P, T, Result> DAO.transactionResult(block: () -> Result): Result = - configuration().dsl().transactionResult(TransactionalCallable { - block() - }) +fun DataAccessException.toPersistenceError(): PersistenceError = + getCause(PSQLException::class.java)?.message.toOption().fold( + { PersistenceError(message) }, + { if (it.contains("duplicate key")) DuplicationError() else PersistenceError(it) } + ) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f989a0c..bb82263 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,8 +23,6 @@ micronaut: - pattern: /** access: - ROLE_ADMIN - basic-auth: - enabled: true router: versioning: enabled: true @@ -56,7 +54,8 @@ flyway: jackson: bean-introspection-module: true serialization-inclusion: always - date-format: "yyyy-MM-dd'T'HH:mm:ssZ" + serialization: + - WRITE_DATES_AS_TIMESTAMPS: false --- jooq: datasources: diff --git a/src/main/resources/db/migration/V1__Create_table_for_monitors.sql b/src/main/resources/db/migration/V1__Create_table_for_monitors.sql deleted file mode 100644 index dc2b297..0000000 --- a/src/main/resources/db/migration/V1__Create_table_for_monitors.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE SCHEMA IF NOT EXISTS kuvasz; - -CREATE TABLE monitor -( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - url TEXT NOT NULL, - uptime_check_interval INTEGER NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL default now(), - updated_at TIMESTAMP WITH TIME ZONE -); - -COMMENT ON COLUMN monitor.name IS 'Monitor''s name'; -COMMENT ON COLUMN monitor.url IS 'URL to check'; -COMMENT ON COLUMN monitor.uptime_check_interval IS 'Uptime checking interval in seconds'; -COMMENT ON COLUMN monitor.enabled IS 'Flag to toggle the monitor'; diff --git a/src/main/resources/db/migration/V2__Add_check_log_table.sql b/src/main/resources/db/migration/V1__Init_schema.sql similarity index 60% rename from src/main/resources/db/migration/V2__Add_check_log_table.sql rename to src/main/resources/db/migration/V1__Init_schema.sql index 83a3500..bcdd1ba 100644 --- a/src/main/resources/db/migration/V2__Add_check_log_table.sql +++ b/src/main/resources/db/migration/V1__Init_schema.sql @@ -1,11 +1,28 @@ -SET SCHEMA 'kuvasz'; +CREATE SCHEMA IF NOT EXISTS kuvasz; + +CREATE TABLE monitor +( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + url TEXT NOT NULL, + uptime_check_interval INTEGER NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL default now(), + updated_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT "unique_monitor_name" UNIQUE ("name") +); + +COMMENT ON COLUMN monitor.name IS 'Monitor''s name'; +COMMENT ON COLUMN monitor.url IS 'URL to check'; +COMMENT ON COLUMN monitor.uptime_check_interval IS 'Uptime checking interval in seconds'; +COMMENT ON COLUMN monitor.enabled IS 'Flag to toggle the monitor'; CREATE TYPE uptime_status AS ENUM ('UP', 'DOWN'); CREATE TABLE uptime_event ( id SERIAL PRIMARY KEY, - monitor_id INTEGER NOT NULL REFERENCES monitor (id), + monitor_id INTEGER NOT NULL REFERENCES monitor (id) ON DELETE CASCADE, status uptime_status NOT NULL, error TEXT, started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), @@ -23,7 +40,7 @@ CREATE INDEX IF NOT EXISTS "uptime_event_ended_at_idx" ON "uptime_event" USING b CREATE TABLE latency_log ( id SERIAL PRIMARY KEY, - monitor_id INTEGER NOT NULL REFERENCES monitor (id), + monitor_id INTEGER NOT NULL REFERENCES monitor (id) ON DELETE CASCADE, latency INTEGER NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); diff --git a/src/main/resources/logback-dev.xml b/src/main/resources/logback-dev.xml index f29fa70..486ed7e 100644 --- a/src/main/resources/logback-dev.xml +++ b/src/main/resources/logback-dev.xml @@ -13,9 +13,9 @@ - + - + diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/DatabaseBehaviorSpec.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/DatabaseBehaviorSpec.kt index 44fdd59..3a04c08 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/DatabaseBehaviorSpec.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/DatabaseBehaviorSpec.kt @@ -6,7 +6,7 @@ import io.kotest.core.test.TestResult import org.flywaydb.core.Flyway import javax.inject.Inject -abstract class DatabaseBehaviorSpec : BehaviorSpec() { +abstract class DatabaseBehaviorSpec(body: BehaviorSpec.() -> Unit = {}) : BehaviorSpec(body) { @Inject lateinit var flyway: Flyway diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/config/AdminAuthConfigTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/config/AdminAuthConfigTest.kt index 2fd7726..c98c68d 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/config/AdminAuthConfigTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/config/AdminAuthConfigTest.kt @@ -3,7 +3,7 @@ package com.kuvaszuptime.kuvasz.config import io.kotest.assertions.exceptionToMessage import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.BehaviorSpec -import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.micronaut.context.ApplicationContext import io.micronaut.context.env.PropertySource import io.micronaut.context.exceptions.BeanInstantiationException @@ -22,7 +22,7 @@ class AdminAuthConfigTest : BehaviorSpec({ val exception = shouldThrow { ApplicationContext.run(properties) } - exceptionToMessage(exception).contains("password - size must be between 12") shouldBe true + exceptionToMessage(exception) shouldContain "password - size must be between 12" } } @@ -38,8 +38,8 @@ class AdminAuthConfigTest : BehaviorSpec({ val exception = shouldThrow { ApplicationContext.run(properties) } - exceptionToMessage(exception).contains("username - must not be blank") shouldBe true - exceptionToMessage(exception).contains("password - must not be blank") shouldBe true + exceptionToMessage(exception) shouldContain "username - must not be blank" + exceptionToMessage(exception) shouldContain "password - must not be blank" } } } diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorClient.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorClient.kt new file mode 100644 index 0000000..1b0b853 --- /dev/null +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorClient.kt @@ -0,0 +1,20 @@ +package com.kuvaszuptime.kuvasz.controllers + +import com.kuvaszuptime.kuvasz.models.dto.MonitorCreateDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorDetailsDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorUpdateDto +import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import io.micronaut.http.client.annotation.Client + +@Client("/monitor") +interface MonitorClient : MonitorOperations { + override fun getMonitor(monitorId: Int): MonitorDetailsDto + + override fun getMonitors(enabledOnly: Boolean?): List + + override fun createMonitor(monitor: MonitorCreateDto): MonitorPojo + + override fun deleteMonitor(monitorId: Int) + + override fun updateMonitor(monitorId: Int, monitorUpdateDto: MonitorUpdateDto): MonitorPojo +} diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorControllerTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorControllerTest.kt new file mode 100644 index 0000000..5664858 --- /dev/null +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/controllers/MonitorControllerTest.kt @@ -0,0 +1,335 @@ +package com.kuvaszuptime.kuvasz.controllers + +import arrow.core.Option +import arrow.core.toOption +import com.kuvaszuptime.kuvasz.DatabaseBehaviorSpec +import com.kuvaszuptime.kuvasz.mocks.createMonitor +import com.kuvaszuptime.kuvasz.models.dto.MonitorCreateDto +import com.kuvaszuptime.kuvasz.models.dto.MonitorUpdateDto +import com.kuvaszuptime.kuvasz.repositories.MonitorRepository +import com.kuvaszuptime.kuvasz.services.CheckScheduler +import com.kuvaszuptime.kuvasz.testutils.shouldBe +import io.kotest.assertions.exceptionToMessage +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.inspectors.forNone +import io.kotest.inspectors.forOne +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.RxHttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.test.annotation.MicronautTest + +@MicronautTest +class MonitorControllerTest( + @Client("/") private val client: RxHttpClient, + private val monitorClient: MonitorClient, + private val monitorRepository: MonitorRepository, + private val checkScheduler: CheckScheduler +) : DatabaseBehaviorSpec() { + + init { + given("MonitorController's getMonitors() endpoint") { + `when`("there is any monitor in the database") { + val monitor = createMonitor(monitorRepository) + val response = monitorClient.getMonitors(enabledOnly = null) + then("it should return them") { + response shouldHaveSize 1 + val responseItem = response.first() + responseItem.id shouldBe monitor.id + responseItem.url.toString() shouldBe monitor.url + responseItem.enabled shouldBe monitor.enabled + responseItem.averageLatencyInMs shouldBe null + responseItem.uptimeStatus shouldBe null + responseItem.createdAt shouldBe monitor.createdAt + } + } + + `when`("enabledOnly parameter is set to true") { + createMonitor(monitorRepository, enabled = false, monitorName = "name1") + val enabledMonitor = createMonitor(monitorRepository, id = 11111, monitorName = "name2") + val response = monitorClient.getMonitors(enabledOnly = true) + then("it should not return disabled monitor") { + response shouldHaveSize 1 + val responseItem = response.first() + responseItem.id shouldBe enabledMonitor.id + responseItem.url.toString() shouldBe enabledMonitor.url + responseItem.enabled shouldBe enabledMonitor.enabled + responseItem.averageLatencyInMs shouldBe null + responseItem.uptimeStatus shouldBe null + responseItem.createdAt shouldBe enabledMonitor.createdAt + } + } + + `when`("there isn't any monitor in the database") { + val response = monitorClient.getMonitors(enabledOnly = false) + then("it should return an empty list") { + response shouldHaveSize 0 + } + } + } + + given("MonitorController's getMonitor() endpoint") { + `when`("there is a monitor with the given ID in the database") { + val monitor = createMonitor(monitorRepository) + val response = monitorClient.getMonitor(monitorId = monitor.id) + then("it should return it") { + response.id shouldBe monitor.id + response.url.toString() shouldBe monitor.url + response.enabled shouldBe monitor.enabled + response.averageLatencyInMs shouldBe null + response.uptimeStatus shouldBe null + response.createdAt shouldBe monitor.createdAt + } + } + + `when`("there is no monitor with the given ID in the database") { + val response = shouldThrow { + client.toBlocking().exchange("/monitor/1232132432") + } + then("it should return a 404") { + response.status shouldBe HttpStatus.NOT_FOUND + } + } + } + + given("MonitorController's createMonitor() endpoint") { + + `when`("it is called with a valid DTO") { + val monitorToCreate = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 6000 + ) + val createdMonitor = monitorClient.createMonitor(monitorToCreate) + + then("it should create a monitor and also schedule checks for it") { + val monitorInDb = monitorRepository.findById(createdMonitor.id)!! + monitorInDb.name shouldBe createdMonitor.name + monitorInDb.url shouldBe createdMonitor.url + monitorInDb.uptimeCheckInterval shouldBe createdMonitor.uptimeCheckInterval + monitorInDb.enabled shouldBe createdMonitor.enabled + monitorInDb.createdAt shouldBe createdMonitor.createdAt + checkScheduler.getScheduledChecks().forOne { it.monitorId shouldBe createdMonitor.id } + } + } + + `when`("there is already a monitor with the same name") { + val firstMonitor = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 6000, + enabled = true + ) + val secondMonitor = MonitorCreateDto( + name = firstMonitor.name, + url = "https://valid-url2.com", + uptimeCheckInterval = 4000, + enabled = false + ) + val firstCreatedMonitor = monitorClient.createMonitor(firstMonitor) + val secondRequest = HttpRequest.POST("/monitor", secondMonitor) + val secondResponse = shouldThrow { + client.toBlocking().exchange(secondRequest) + } + + then("it should return a 409") { + secondResponse.status shouldBe HttpStatus.CONFLICT + val monitorsInDb = monitorRepository.fetchByName(firstCreatedMonitor.name) + monitorsInDb shouldHaveSize 1 + checkScheduler.getScheduledChecks().forOne { it.monitorId shouldBe firstCreatedMonitor.id } + } + } + + `when`("it is called with an invalid URL") { + val monitorToCreate = MonitorCreateDto( + name = "test_monitor", + url = "htt://invalid-url.com", + uptimeCheckInterval = 6000, + enabled = true + ) + val request = HttpRequest.POST("/monitor", monitorToCreate) + val response = shouldThrow { + client.toBlocking().exchange(request) + } + + then("it should return a 400") { + response.status shouldBe HttpStatus.BAD_REQUEST + exceptionToMessage(response) shouldContain "url: must match \"^(https?)" + } + } + + `when`("it is called with an invalid uptime check interval") { + val monitorToCreate = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 59, + enabled = true + ) + val request = HttpRequest.POST("/monitor", monitorToCreate) + val response = shouldThrow { + client.toBlocking().exchange(request) + } + + then("it should return a 400") { + response.status shouldBe HttpStatus.BAD_REQUEST + exceptionToMessage(response) shouldContain "uptimeCheckInterval: must be greater than or equal to 60" + } + } + } + + given("MonitorController's deleteMonitor() endpoint") { + + `when`("it is called with an existing monitor ID") { + val monitorToCreate = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 6000, + enabled = true + ) + val createdMonitor = monitorClient.createMonitor(monitorToCreate) + val deleteRequest = HttpRequest.DELETE("/monitor/${createdMonitor.id}") + val response = client.toBlocking().exchange(deleteRequest) + val monitorInDb = monitorRepository.findById(createdMonitor.id).toOption() + + then("it should delete the monitor and also remove the checks of it") { + response.status shouldBe HttpStatus.NO_CONTENT + monitorInDb shouldBe Option.empty() + + checkScheduler.getScheduledChecks().forNone { it.monitorId shouldBe createdMonitor.id } + } + } + + `when`("it is called with a non existing monitor ID") { + val deleteRequest = HttpRequest.DELETE("/monitor/123232") + val response = shouldThrow { + client.toBlocking().exchange(deleteRequest) + } + + then("it should return a 404") { + response.status shouldBe HttpStatus.NOT_FOUND + } + } + } + + given("MonitorController's updateMonitor() endpoint") { + + `when`("it is called with an existing monitor ID and a valid DTO to disable the monitor") { + val createDto = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 6000, + enabled = true + ) + val createdMonitor = monitorClient.createMonitor(createDto) + checkScheduler.getScheduledChecks().forOne { it.monitorId shouldBe createdMonitor.id } + + val updateDto = MonitorUpdateDto( + name = "updated_test_monitor", + url = "https://updated-url.com", + uptimeCheckInterval = 5000, + enabled = false + ) + val updatedMonitor = monitorClient.updateMonitor(createdMonitor.id, updateDto) + val monitorInDb = monitorRepository.findById(createdMonitor.id)!! + + then("it should update the monitor and remove the checks of it") { + monitorInDb.name shouldBe updatedMonitor.name + monitorInDb.url shouldBe updatedMonitor.url + monitorInDb.uptimeCheckInterval shouldBe updatedMonitor.uptimeCheckInterval + monitorInDb.enabled shouldBe updatedMonitor.enabled + monitorInDb.createdAt shouldBe createdMonitor.createdAt + monitorInDb.updatedAt shouldNotBe null + + checkScheduler.getScheduledChecks().forNone { it.monitorId shouldBe createdMonitor.id } + } + } + + `when`("it is called with an existing monitor ID and a valid DTO to enable the monitor") { + val createDto = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 6000, + enabled = false + ) + val createdMonitor = monitorClient.createMonitor(createDto) + checkScheduler.getScheduledChecks().forNone { it.monitorId shouldBe createdMonitor.id } + + val updateDto = MonitorUpdateDto( + name = null, + url = null, + uptimeCheckInterval = null, + enabled = true + ) + val updatedMonitor = monitorClient.updateMonitor(createdMonitor.id, updateDto) + val monitorInDb = monitorRepository.findById(createdMonitor.id)!! + + then("it should update the monitor and create the checks of it") { + monitorInDb.name shouldBe createdMonitor.name + monitorInDb.url shouldBe createdMonitor.url + monitorInDb.uptimeCheckInterval shouldBe createdMonitor.uptimeCheckInterval + monitorInDb.enabled shouldBe updatedMonitor.enabled + monitorInDb.createdAt shouldBe createdMonitor.createdAt + monitorInDb.updatedAt shouldNotBe null + + checkScheduler.getScheduledChecks().forOne { it.monitorId shouldBe createdMonitor.id } + } + } + + `when`("it is called with an existing monitor ID but there is an other monitor with the given name") { + val firstCreateDto = MonitorCreateDto( + name = "test_monitor", + url = "https://valid-url.com", + uptimeCheckInterval = 6000 + ) + val firstCreatedMonitor = monitorClient.createMonitor(firstCreateDto) + val secondCreateDto = MonitorCreateDto( + name = "test_monitor2", + url = "https://valid-url2.com", + uptimeCheckInterval = 6000 + ) + val secondCreatedMonitor = monitorClient.createMonitor(secondCreateDto) + + val updateDto = MonitorUpdateDto( + name = secondCreatedMonitor.name, + url = null, + uptimeCheckInterval = null, + enabled = true + ) + val updateRequest = HttpRequest.PATCH("/monitor/${firstCreatedMonitor.id}", updateDto) + val response = shouldThrow { + client.toBlocking().exchange(updateRequest) + } + val monitorInDb = monitorRepository.findById(firstCreatedMonitor.id)!! + + then("it should return a 409") { + response.status shouldBe HttpStatus.CONFLICT + monitorInDb.name shouldBe firstCreatedMonitor.name + } + } + + `when`("it is called with a non existing monitor ID") { + val updateDto = MonitorUpdateDto(null, null, null, null) + val updateRequest = HttpRequest.PATCH("/monitor/123232", updateDto) + val response = shouldThrow { + client.toBlocking().exchange(updateRequest) + } + + then("it should return a 404") { + response.status shouldBe HttpStatus.NOT_FOUND + } + } + } + } + + override fun afterTest(testCase: TestCase, result: TestResult) { + checkScheduler.removeAllChecks() + super.afterTest(testCase, result) + } +} diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/DatabaseEventHandlerTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/DatabaseEventHandlerTest.kt index a72cd3e..7d0ec06 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/DatabaseEventHandlerTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/DatabaseEventHandlerTest.kt @@ -3,9 +3,9 @@ package com.kuvaszuptime.kuvasz.handlers import arrow.core.Option import com.kuvaszuptime.kuvasz.DatabaseBehaviorSpec import com.kuvaszuptime.kuvasz.enums.UptimeStatus +import com.kuvaszuptime.kuvasz.mocks.createMonitor import com.kuvaszuptime.kuvasz.models.MonitorDownEvent import com.kuvaszuptime.kuvasz.models.MonitorUpEvent -import com.kuvaszuptime.kuvasz.mocks.createMonitor import com.kuvaszuptime.kuvasz.repositories.LatencyLogRepository import com.kuvaszuptime.kuvasz.repositories.MonitorRepository import com.kuvaszuptime.kuvasz.repositories.UptimeEventRepository @@ -14,6 +14,7 @@ import com.kuvaszuptime.kuvasz.tables.LatencyLog.LATENCY_LOG import com.kuvaszuptime.kuvasz.tables.UptimeEvent.UPTIME_EVENT import io.kotest.core.test.TestCase import io.kotest.core.test.TestResult +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.micronaut.http.HttpStatus import io.micronaut.test.annotation.MicronautTest @@ -118,7 +119,7 @@ class DatabaseEventHandlerTest( expectedUptimeRecord.status shouldBe UptimeStatus.UP expectedUptimeRecord.endedAt shouldBe null - latencyRecords.size shouldBe 2 + latencyRecords shouldHaveSize 2 latencyRecords[0].latency shouldBe firstEvent.latency latencyRecords[1].latency shouldBe secondEvent.latency } diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/mocks/TestData.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/mocks/TestData.kt index 440f765..08cbe52 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/mocks/TestData.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/mocks/TestData.kt @@ -2,6 +2,7 @@ package com.kuvaszuptime.kuvasz.mocks import com.kuvaszuptime.kuvasz.repositories.MonitorRepository import com.kuvaszuptime.kuvasz.tables.pojos.MonitorPojo +import com.kuvaszuptime.kuvasz.util.getCurrentTimestamp fun createMonitor( repository: MonitorRepository, @@ -17,6 +18,7 @@ fun createMonitor( .setUptimeCheckInterval(uptimeCheckInterval) .setUrl(url) .setEnabled(enabled) + .setCreatedAt(getCurrentTimestamp()) repository.insert(monitor) return monitor } diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/security/AuthenticationTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/security/AuthenticationTest.kt index 05c5d27..4051417 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/security/AuthenticationTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/security/AuthenticationTest.kt @@ -8,6 +8,7 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.micronaut.context.annotation.Property import io.micronaut.http.HttpRequest import io.micronaut.http.HttpStatus import io.micronaut.http.client.RxHttpClient @@ -17,70 +18,70 @@ import io.micronaut.security.token.jwt.render.BearerAccessRefreshToken import io.micronaut.test.annotation.MicronautTest @MicronautTest +@Property(name = "micronaut.security.enabled", value = "true") class AuthenticationTest( @Client("/") private val client: RxHttpClient, private val authConfig: AdminAuthConfig -) : BehaviorSpec() { - init { - given("a public endpoint") { +) : BehaviorSpec({ - `when`("an anonymous user calls it") { - val response = client.toBlocking().exchange("/health") - then("it should return 200") { - response.status shouldBe HttpStatus.OK - } + given("a public endpoint") { + + `when`("an anonymous user calls it") { + val response = client.toBlocking().exchange("/health") + then("it should return 200") { + response.status shouldBe HttpStatus.OK } } - given("the login endpoint") { + } + given("the login endpoint") { - `when`("the user provides the right credentials") { - val credentials = generateCredentials(authConfig, valid = true) - val request = HttpRequest.POST("/login", credentials) - val response = client.toBlocking().exchange(request, BearerAccessRefreshToken::class.java) - val token = response.body()!! - val parsedJwt = JWTParser.parse(token.accessToken) - then("it should return a signed access token for the given user") { - response.status shouldBe HttpStatus.OK - token.username shouldBe credentials.username - token.accessToken shouldNotBe null - (parsedJwt is SignedJWT) shouldBe true - } + `when`("the user provides the right credentials") { + val credentials = generateCredentials(authConfig, valid = true) + val request = HttpRequest.POST("/login", credentials) + val response = client.toBlocking().exchange(request, BearerAccessRefreshToken::class.java) + val token = response.body()!! + val parsedJwt = JWTParser.parse(token.accessToken) + then("it should return a signed access token for the given user") { + response.status shouldBe HttpStatus.OK + token.username shouldBe credentials.username + token.accessToken shouldNotBe null + (parsedJwt is SignedJWT) shouldBe true } + } - `when`("a user provides bad credentials") { - val credentials = generateCredentials(authConfig, valid = false) - val request = HttpRequest.POST("/login", credentials) - val exception = shouldThrow { - client.toBlocking().exchange(request, BearerAccessRefreshToken::class.java) - } - then("it should return 401") { - exception.status shouldBe HttpStatus.UNAUTHORIZED - } + `when`("a user provides bad credentials") { + val credentials = generateCredentials(authConfig, valid = false) + val request = HttpRequest.POST("/login", credentials) + val exception = shouldThrow { + client.toBlocking().exchange(request, BearerAccessRefreshToken::class.java) + } + then("it should return 401") { + exception.status shouldBe HttpStatus.UNAUTHORIZED } } - given("an authenticated endpoint") { + } + given("an authenticated endpoint") { - `when`("an anonymous user calls it") { - val exception = shouldThrow { - client.toBlocking().exchange("/hello") - } - then("it should return 401") { - exception.status shouldBe HttpStatus.UNAUTHORIZED - } + `when`("an anonymous user calls it") { + val exception = shouldThrow { + client.toBlocking().exchange("/monitor") + } + then("it should return 401") { + exception.status shouldBe HttpStatus.UNAUTHORIZED } + } - `when`("a user provides the right credentials") { - val credentials = generateCredentials(authConfig, valid = true) - val loginRequest = HttpRequest.POST("/login", credentials) - val loginResponse = client.toBlocking().exchange(loginRequest, BearerAccessRefreshToken::class.java) - val token = loginResponse.body()!! + `when`("a user provides the right credentials") { + val credentials = generateCredentials(authConfig, valid = true) + val loginRequest = HttpRequest.POST("/login", credentials) + val loginResponse = client.toBlocking().exchange(loginRequest, BearerAccessRefreshToken::class.java) + val token = loginResponse.body()!! - val request = HttpRequest.GET("/hello").bearerAuth(token.accessToken) - val response = client.toBlocking().exchange(request) - then("it should return 200") { - response.status shouldBe HttpStatus.OK - } + val request = HttpRequest.GET("/monitor").bearerAuth(token.accessToken) + val response = client.toBlocking().exchange(request) + then("it should return 200") { + response.status shouldBe HttpStatus.OK } } } -} +}) diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/services/CheckSchedulerTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/services/CheckSchedulerTest.kt index 0e2a5f6..deb6112 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/services/CheckSchedulerTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/services/CheckSchedulerTest.kt @@ -4,6 +4,7 @@ import com.kuvaszuptime.kuvasz.DatabaseBehaviorSpec import com.kuvaszuptime.kuvasz.mocks.createMonitor import com.kuvaszuptime.kuvasz.models.CheckType import com.kuvaszuptime.kuvasz.repositories.MonitorRepository +import io.kotest.inspectors.forNone import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.micronaut.test.annotation.MicronautTest @@ -12,42 +13,40 @@ import io.micronaut.test.annotation.MicronautTest class CheckSchedulerTest( private val checkScheduler: CheckScheduler, private val monitorRepository: MonitorRepository -) : DatabaseBehaviorSpec() { - init { - given("the CheckScheduler service") { - `when`("there is an enabled monitor in the database and initialize has been called") { - val monitor = createMonitor(monitorRepository) - - checkScheduler.initialize() - - then("it should schedule the check for it") { - val expectedCheck = checkScheduler.getScheduledChecks().find { it.monitorId == monitor.id } - expectedCheck shouldNotBe null - expectedCheck!!.checkType shouldBe CheckType.UPTIME - expectedCheck.task.isCancelled shouldBe false - expectedCheck.task.isDone shouldBe false - } +) : DatabaseBehaviorSpec({ + given("the CheckScheduler service") { + `when`("there is an enabled monitor in the database and initialize has been called") { + val monitor = createMonitor(monitorRepository) + + checkScheduler.initialize() + + then("it should schedule the check for it") { + val expectedCheck = checkScheduler.getScheduledChecks().find { it.monitorId == monitor.id } + expectedCheck shouldNotBe null + expectedCheck!!.checkType shouldBe CheckType.UPTIME + expectedCheck.task.isCancelled shouldBe false + expectedCheck.task.isDone shouldBe false } + } - `when`("there is an enabled but unschedulable monitor in the database and initialize has been called") { - val monitor = createMonitor(monitorRepository, id = 88888, uptimeCheckInterval = 0) + `when`("there is an enabled but unschedulable monitor in the database and initialize has been called") { + val monitor = createMonitor(monitorRepository, id = 88888, uptimeCheckInterval = 0) - checkScheduler.initialize() + checkScheduler.initialize() - then("it should not schedule the check for it") { - checkScheduler.getScheduledChecks().any { it.monitorId == monitor.id } shouldBe false - } + then("it should not schedule the check for it") { + checkScheduler.getScheduledChecks().forNone { it.monitorId shouldBe monitor.id } } + } - `when`("there is a disabled monitor in the database and initialize has been called") { - val monitor = createMonitor(monitorRepository, id = 11111, enabled = false) + `when`("there is a disabled monitor in the database and initialize has been called") { + val monitor = createMonitor(monitorRepository, id = 11111, enabled = false) - checkScheduler.initialize() + checkScheduler.initialize() - then("it should not schedule the check for it") { - checkScheduler.getScheduledChecks().any { it.monitorId == monitor.id } shouldBe false - } + then("it should not schedule the check for it") { + checkScheduler.getScheduledChecks().forNone { it.monitorId shouldBe monitor.id } } } } -} +}) diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/services/UptimeCheckerTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/services/UptimeCheckerTest.kt index ac52ca1..f6035a4 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/services/UptimeCheckerTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/services/UptimeCheckerTest.kt @@ -1,15 +1,17 @@ package com.kuvaszuptime.kuvasz.services import com.kuvaszuptime.kuvasz.DatabaseBehaviorSpec +import com.kuvaszuptime.kuvasz.mocks.createMonitor import com.kuvaszuptime.kuvasz.models.MonitorDownEvent import com.kuvaszuptime.kuvasz.models.MonitorUpEvent import com.kuvaszuptime.kuvasz.models.RedirectEvent -import com.kuvaszuptime.kuvasz.mocks.createMonitor import com.kuvaszuptime.kuvasz.repositories.MonitorRepository -import com.kuvaszuptime.kuvasz.util.toUri import com.kuvaszuptime.kuvasz.testutils.toSubscriber +import com.kuvaszuptime.kuvasz.util.toUri import io.kotest.core.test.TestCase import io.kotest.core.test.TestResult +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.comparables.shouldBeLessThan import io.kotest.matchers.shouldBe import io.micronaut.http.HttpHeaders import io.micronaut.http.HttpStatus @@ -63,6 +65,56 @@ class UptimeCheckerTest( } } + `when`("it checks a monitor that is DOWN but then it's UP again") { + val monitor = createMonitor(monitorRepository) + val monitorUpSubscriber = TestSubscriber() + val monitorDownSubscriber = TestSubscriber() + eventDispatcher.subscribeToMonitorUpEvents { it.toSubscriber(monitorUpSubscriber) } + eventDispatcher.subscribeToMonitorDownEvents { it.toSubscriber(monitorDownSubscriber) } + mockHttpResponse(uptimeCheckerSpy, HttpStatus.NOT_FOUND) + + then("it should dispatch a MonitorDownEvent") { + uptimeCheckerSpy.check(monitor) + clearAllMocks() + mockHttpResponse(uptimeCheckerSpy, HttpStatus.OK) + uptimeCheckerSpy.check(monitor) + + val expectedDownEvent = monitorDownSubscriber.values().first() + val expectedUpEvent = monitorUpSubscriber.values().first() + + monitorDownSubscriber.valueCount() shouldBe 1 + monitorUpSubscriber.valueCount() shouldBe 1 + expectedDownEvent.monitor.id shouldBe monitor.id + expectedUpEvent.monitor.id shouldBe monitor.id + expectedDownEvent.dispatchedAt shouldBeLessThan expectedUpEvent.dispatchedAt + } + } + + `when`("it checks a monitor that is UP but then it's DOWN again") { + val monitor = createMonitor(monitorRepository) + val monitorUpSubscriber = TestSubscriber() + val monitorDownSubscriber = TestSubscriber() + eventDispatcher.subscribeToMonitorUpEvents { it.toSubscriber(monitorUpSubscriber) } + eventDispatcher.subscribeToMonitorDownEvents { it.toSubscriber(monitorDownSubscriber) } + mockHttpResponse(uptimeCheckerSpy, HttpStatus.OK) + + then("it should dispatch a MonitorDownEvent") { + uptimeCheckerSpy.check(monitor) + clearAllMocks() + mockHttpResponse(uptimeCheckerSpy, HttpStatus.NOT_FOUND) + uptimeCheckerSpy.check(monitor) + + val expectedDownEvent = monitorDownSubscriber.values().first() + val expectedUpEvent = monitorUpSubscriber.values().first() + + monitorDownSubscriber.valueCount() shouldBe 1 + monitorUpSubscriber.valueCount() shouldBe 1 + expectedDownEvent.monitor.id shouldBe monitor.id + expectedUpEvent.monitor.id shouldBe monitor.id + expectedDownEvent.dispatchedAt shouldBeGreaterThan expectedUpEvent.dispatchedAt + } + } + `when`("it checks a monitor that is redirected without a Location header") { val monitor = createMonitor(monitorRepository) val subscriber = TestSubscriber() diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/testutils/Matchers+.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/testutils/Matchers+.kt new file mode 100644 index 0000000..1ac91e8 --- /dev/null +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/testutils/Matchers+.kt @@ -0,0 +1,7 @@ +package com.kuvaszuptime.kuvasz.testutils + +import io.kotest.matchers.booleans.shouldBeTrue +import java.time.OffsetDateTime + +infix fun OffsetDateTime.shouldBe(otherOffsetDateTime: OffsetDateTime) = + isEqual(otherOffsetDateTime).shouldBeTrue() diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/testutils/MicronautTestUtils.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/testutils/MicronautTestUtils.kt new file mode 100644 index 0000000..a7859ce --- /dev/null +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/testutils/MicronautTestUtils.kt @@ -0,0 +1,26 @@ +package com.kuvaszuptime.kuvasz.testutils + +import io.micronaut.context.ApplicationContext +import io.micronaut.http.client.RxHttpClient +import io.micronaut.runtime.server.EmbeddedServer + +fun startTestApplication(mockBeans: List = emptyList(), withRealAuth: Boolean = false): EmbeddedServer = + ApplicationContext + .build() + .apply { + if (withRealAuth) { + properties(mapOf("micronaut.security.enabled" to "true")) + } + } + .build() + .apply { + mockBeans.forEach { registerSingleton(it) } + } + .start() + .getBean(EmbeddedServer::class.java) + .start() + +inline fun EmbeddedServer.getBean(): T = this.applicationContext.getBean(T::class.java) + +fun EmbeddedServer.getLowLevelClient(): RxHttpClient = + this.applicationContext.createBean(RxHttpClient::class.java, this.url) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8d710d2..c98be4f 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -7,6 +7,7 @@ micronaut: secret: generator: secret: testSecretItsVeryVerySecretSecret + enabled: false --- app-config: http-communication-logging: