diff --git a/README.md b/README.md index 1ed9dac6..fc2e8545 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ with updates and ReVanced Patches. Some of the features ReVanced API include: -- 📢 **Announcements**: Post and get announcements grouped by channels +- 📢 **Announcements**: Post and get announcements - ℹ️ **About**: Get more information such as a description, ways to donate to, and links of the hoster of ReVanced API - 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API diff --git a/build.gradle.kts b/build.gradle.kts index 0ee566e0..0df4c07e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,6 +48,12 @@ kotlin { } } +tasks { + test { + useJUnitPlatform() + } +} + repositories { mavenCentral() google() @@ -98,6 +104,8 @@ dependencies { implementation(libs.caffeine) implementation(libs.bouncy.castle.provider) implementation(libs.bouncy.castle.pgp) + + testImplementation(kotlin("test")) } // The maven-publish plugin is necessary to make signing work. diff --git a/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt b/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt index 992b06ff..70052685 100644 --- a/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt +++ b/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt @@ -1,10 +1,10 @@ package app.revanced.api.configuration.repository -import app.revanced.api.configuration.schema.APIAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncementId +import app.revanced.api.configuration.schema.ApiAnnouncement +import app.revanced.api.configuration.schema.ApiAnnouncementTag +import app.revanced.api.configuration.schema.ApiResponseAnnouncement +import app.revanced.api.configuration.schema.ApiResponseAnnouncementId import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import kotlinx.datetime.* import org.jetbrains.exposed.dao.IntEntity @@ -12,129 +12,178 @@ import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime import org.jetbrains.exposed.sql.kotlin.datetime.datetime import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction -import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync internal class AnnouncementRepository { - // This is better than doing a maxByOrNull { it.id }. + // This is better than doing a maxByOrNull { it.id } on every request. private var latestAnnouncement: Announcement? = null - private val latestAnnouncementByChannel = mutableMapOf() - - private fun updateLatestAnnouncement(new: Announcement) { - if (latestAnnouncement?.id?.value == new.id.value) { - latestAnnouncement = new - latestAnnouncementByChannel[new.channel ?: return] = new - } - } + private val latestAnnouncementByTag = mutableMapOf() init { runBlocking { transaction { - SchemaUtils.create(Announcements, Attachments) + SchemaUtils.create( + Announcements, + Attachments, + Tags, + AnnouncementTags, + ) - // Initialize the latest announcement. - latestAnnouncement = Announcement.all().onEach { - latestAnnouncementByChannel[it.channel ?: return@onEach] = it - }.maxByOrNull { it.id } ?: return@transaction + initializeLatestAnnouncements() } } } - suspend fun all() = transaction { - Announcement.all().map { it.toApi() } - } + private fun initializeLatestAnnouncements() { + latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull() - suspend fun all(channel: String) = transaction { - Announcement.find { Announcements.channel eq channel }.map { it.toApi() } + Tag.all().map { it.id.value }.forEach(::updateLatestAnnouncementForTag) } - suspend fun delete(id: Int) = transaction { - val announcement = Announcement.findById(id) ?: return@transaction + private fun updateLatestAnnouncement(new: Announcement) { + if (latestAnnouncement == null || latestAnnouncement!!.id.value <= new.id.value) { + latestAnnouncement = new + new.tags.forEach { tag -> latestAnnouncementByTag[tag.id.value] = new } + } + } - announcement.delete() + private fun updateLatestAnnouncementForTag(tag: Int) { + val latestAnnouncementForTag = AnnouncementTags.select(AnnouncementTags.announcement) + .where { AnnouncementTags.tag eq tag } + .map { it[AnnouncementTags.announcement] } + .mapNotNull { Announcement.findById(it) } + .maxByOrNull { it.id } - // In case the latest announcement was deleted, query the new latest announcement again. - if (latestAnnouncement?.id?.value == id) { - latestAnnouncement = Announcement.all().maxByOrNull { it.id } + latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it } + } - // If no latest announcement was found, remove it from the channel map. - if (latestAnnouncement == null) { - latestAnnouncementByChannel.remove(announcement.channel) - } else { - latestAnnouncementByChannel[latestAnnouncement!!.channel ?: return@transaction] = latestAnnouncement!! - } - } + suspend fun latest() = transaction { + latestAnnouncement.toApiResponseAnnouncement() } - fun latest() = latestAnnouncement?.toApi() + suspend fun latest(tags: Set) = transaction { + tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement() + } - fun latest(channel: String) = latestAnnouncementByChannel[channel]?.toApi() + fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId() - fun latestId() = latest()?.id?.toApi() + fun latestId(tags: Set) = + tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId() - fun latestId(channel: String) = latest(channel)?.id?.toApi() + suspend fun paged(offset: Int, count: Int, tags: Set?) = transaction { + if (tags == null) { + Announcement.all() + } else { + @Suppress("NAME_SHADOWING") + val tags = tags.mapNotNull { Tag.findById(it)?.id } - suspend fun archive( - id: Int, - archivedAt: LocalDateTime?, - ) = transaction { - Announcement.findByIdAndUpdate(id) { - it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime() - }?.also(::updateLatestAnnouncement) + Announcement.find { + Announcements.id inSubQuery Announcements.innerJoin(AnnouncementTags) + .select(Announcements.id) + .where { AnnouncementTags.tag inList tags } + .withDistinct() + } + }.orderBy(Announcements.id to SortOrder.DESC).limit(count, offset.toLong()).map { it }.toApiAnnouncement() } - suspend fun unarchive(id: Int) = transaction { - Announcement.findByIdAndUpdate(id) { - it.archivedAt = null - }?.also(::updateLatestAnnouncement) + suspend fun get(id: Int) = transaction { + Announcement.findById(id).toApiResponseAnnouncement() } - suspend fun new(new: APIAnnouncement) = transaction { + suspend fun new(new: ApiAnnouncement) = transaction { Announcement.new { author = new.author title = new.title content = new.content - channel = new.channel archivedAt = new.archivedAt level = new.level - }.also { newAnnouncement -> - new.attachmentUrls.map { newUrl -> - suspendedTransactionAsync { - Attachment.new { - url = newUrl - announcement = newAnnouncement - } + tags = SizedCollection( + new.tags.map { tag -> Tag.find { Tags.name eq tag }.firstOrNull() ?: Tag.new { name = tag } }, + ) + }.apply { + new.attachments.map { attachmentUrl -> + Attachment.new { + url = attachmentUrl + announcement = this@apply } - }.awaitAll() - }.also(::updateLatestAnnouncement) + } + }.let(::updateLatestAnnouncement) } - suspend fun update(id: Int, new: APIAnnouncement) = transaction { + suspend fun update(id: Int, new: ApiAnnouncement) = transaction { Announcement.findByIdAndUpdate(id) { it.author = new.author it.title = new.title it.content = new.content - it.channel = new.channel it.archivedAt = new.archivedAt it.level = new.level - }?.also { newAnnouncement -> - newAnnouncement.attachments.map { - suspendedTransactionAsync { - it.delete() - } - }.awaitAll() - - new.attachmentUrls.map { newUrl -> - suspendedTransactionAsync { - Attachment.new { - url = newUrl - announcement = newAnnouncement - } + + // Get the old tags, create new tags if they don't exist, + // and delete tags that are not in the new tags, after updating the announcement. + val oldTags = it.tags.toList() + val updatedTags = new.tags.map { name -> + Tag.find { Tags.name eq name }.firstOrNull() ?: Tag.new { this.name = name } + } + it.tags = SizedCollection(updatedTags) + oldTags.forEach { tag -> + if (tag in updatedTags || !tag.announcements.empty()) return@forEach + tag.delete() + } + + // Delete old attachments and create new attachments. + it.attachments.forEach { attachment -> attachment.delete() } + new.attachments.map { attachment -> + Attachment.new { + url = attachment + announcement = it } - }.awaitAll() - }?.also(::updateLatestAnnouncement) + } + }?.let(::updateLatestAnnouncement) ?: Unit + } + + suspend fun delete(id: Int) = transaction { + val announcement = Announcement.findById(id) ?: return@transaction + + // Delete the tag if no other announcements are referencing it. + // One count means that the announcement is the only one referencing the tag. + announcement.tags.filter { tag -> tag.announcements.count() == 1L }.forEach { tag -> + latestAnnouncementByTag -= tag.id.value + tag.delete() + } + + announcement.delete() + + // If the deleted announcement is the latest announcement, set the new latest announcement. + if (latestAnnouncement?.id?.value == id) { + latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull() + } + + // The new announcement may be the latest for a specific tag. Set the new latest announcement for that tag. + latestAnnouncementByTag.keys.forEach { tag -> + updateLatestAnnouncementForTag(tag) + } + } + + suspend fun archive( + id: Int, + archivedAt: LocalDateTime?, + ) = transaction { + Announcement.findByIdAndUpdate(id) { + it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime() + }?.let(::updateLatestAnnouncement) + } + + suspend fun unarchive(id: Int) = transaction { + Announcement.findByIdAndUpdate(id) { + it.archivedAt = null + }?.let(::updateLatestAnnouncement) ?: Unit + } + + suspend fun tags() = transaction { + Tag.all().toList().toApiTag() } private suspend fun transaction(statement: suspend Transaction.() -> T) = @@ -144,7 +193,6 @@ internal class AnnouncementRepository { val author = varchar("author", 32).nullable() val title = varchar("title", 64) val content = text("content").nullable() - val channel = varchar("channel", 16).nullable() val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime) val archivedAt = datetime("archivedAt").nullable() val level = integer("level") @@ -155,6 +203,19 @@ internal class AnnouncementRepository { val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE) } + private object Tags : IntIdTable() { + val name = varchar("name", 16).uniqueIndex() + } + + private object AnnouncementTags : Table() { + val tag = reference("tag", Tags, onDelete = ReferenceOption.CASCADE) + val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE) + + init { + uniqueIndex(tag, announcement) + } + } + class Announcement(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Announcements) @@ -162,7 +223,7 @@ internal class AnnouncementRepository { var title by Announcements.title var content by Announcements.content val attachments by Attachment referrersOn Attachments.announcement - var channel by Announcements.channel + var tags by Tag via AnnouncementTags var createdAt by Announcements.createdAt var archivedAt by Announcements.archivedAt var level by Announcements.level @@ -175,17 +236,32 @@ internal class AnnouncementRepository { var announcement by Announcement referencedOn Attachments.announcement } - private fun Announcement.toApi() = APIResponseAnnouncement( - id.value, - author, - title, - content, - attachments.map { it.url }, - channel, - createdAt, - archivedAt, - level, - ) + class Tag(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Tags) + + var name by Tags.name + var announcements by Announcement via AnnouncementTags + } + + private fun Announcement?.toApiResponseAnnouncement() = this?.let { + ApiResponseAnnouncement( + id.value, + author, + title, + content, + attachments.map { it.url }, + tags.map { it.id.value }, + createdAt, + archivedAt, + level, + ) + } + + private fun List.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! } + + private fun List.toApiTag() = map { ApiAnnouncementTag(it.id.value, it.name) } + + private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) } - private fun Int.toApi() = APIResponseAnnouncementId(this) + private fun List.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() } } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt b/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt index 2f2ffd88..1948f4dc 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt @@ -4,10 +4,10 @@ import app.revanced.api.configuration.canRespondUnauthorized import app.revanced.api.configuration.installCache import app.revanced.api.configuration.installNotarizedRoute import app.revanced.api.configuration.respondOrNotFound -import app.revanced.api.configuration.schema.APIAnnouncement -import app.revanced.api.configuration.schema.APIAnnouncementArchivedAt -import app.revanced.api.configuration.schema.APIResponseAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncementId +import app.revanced.api.configuration.schema.ApiAnnouncement +import app.revanced.api.configuration.schema.ApiAnnouncementArchivedAt +import app.revanced.api.configuration.schema.ApiResponseAnnouncement +import app.revanced.api.configuration.schema.ApiResponseAnnouncementId import app.revanced.api.configuration.services.AnnouncementService import io.bkbn.kompendium.core.metadata.* import io.bkbn.kompendium.json.schema.definition.TypeDefinition @@ -32,76 +32,62 @@ internal fun Route.announcementsRoute() = route("announcements") { rateLimit(RateLimitName("strong")) { get { - call.respond(announcementService.all()) - } - } - - rateLimit(RateLimitName("strong")) { - route("{channel}/latest") { - installLatestChannelAnnouncementRouteDocumentation() - - get { - val channel: String by call.parameters - - call.respondOrNotFound(announcementService.latest(channel)) - } - - route("id") { - installLatestChannelAnnouncementIdRouteDocumentation() - - get { - val channel: String by call.parameters + val offset = call.parameters["offset"]?.toInt() ?: 0 + val count = call.parameters["count"]?.toInt() ?: 16 + val tags = call.parameters.getAll("tag") - call.respondOrNotFound(announcementService.latestId(channel)) - } - } + call.respond(announcementService.paged(offset, count, tags?.map { it.toInt() }?.toSet())) } } - rateLimit(RateLimitName("strong")) { - route("{channel}") { - installChannelAnnouncementsRouteDocumentation() - - get { - val channel: String by call.parameters + rateLimit(RateLimitName("weak")) { + authenticate("jwt") { + post { announcement -> + announcementService.new(announcement) - call.respond(announcementService.all(channel)) + call.respond(HttpStatusCode.OK) } } - } - rateLimit(RateLimitName("strong")) { route("latest") { - installLatestAnnouncementRouteDocumentation() + installAnnouncementsLatestRouteDocumentation() get { - call.respondOrNotFound(announcementService.latest()) + val tags = call.parameters.getAll("tag") + + if (tags?.isNotEmpty() == true) { + call.respond(announcementService.latest(tags.map { it.toInt() }.toSet())) + } else { + call.respondOrNotFound(announcementService.latest()) + } } route("id") { - installLatestAnnouncementIdRouteDocumentation() + installAnnouncementsLatestIdRouteDocumentation() get { - call.respondOrNotFound(announcementService.latestId()) + val tags = call.parameters.getAll("tag") + + if (tags?.isNotEmpty() == true) { + call.respond(announcementService.latestId(tags.map { it.toInt() }.toSet())) + } else { + call.respondOrNotFound(announcementService.latestId()) + } } } } - } - rateLimit(RateLimitName("strong")) { - authenticate("jwt") { - installAnnouncementRouteDocumentation() + route("{id}") { + installAnnouncementsIdRouteDocumentation() - post { announcement -> - announcementService.new(announcement) + get { + val id: Int by call.parameters - call.respond(HttpStatusCode.OK) + call.respondOrNotFound(announcementService.get(id)) } - route("{id}") { - installAnnouncementIdRouteDocumentation() - - patch { announcement -> + authenticate("jwt") { + patch { announcement -> val id: Int by call.parameters announcementService.update(id, announcement) @@ -116,31 +102,35 @@ internal fun Route.announcementsRoute() = route("announcements") { call.respond(HttpStatusCode.OK) } + } + } - route("archive") { - installAnnouncementArchiveRouteDocumentation() + route("archive") { + installAnnouncementsArchiveRouteDocumentation() - post { - val id: Int by call.parameters - val archivedAt = call.receiveNullable()?.archivedAt + post { + val id: Int by call.parameters + val archivedAt = call.receiveNullable()?.archivedAt - announcementService.archive(id, archivedAt) + announcementService.archive(id, archivedAt) - call.respond(HttpStatusCode.OK) - } - } + call.respond(HttpStatusCode.OK) + } - route("unarchive") { - installAnnouncementUnarchiveRouteDocumentation() + delete { + val id: Int by call.parameters - post { - val id: Int by call.parameters + announcementService.unarchive(id) - announcementService.unarchive(id) + call.respond(HttpStatusCode.OK) + } + } - call.respond(HttpStatusCode.OK) - } - } + route("tags") { + installAnnouncementsTagsRouteDocumentation() + + get { + call.respond(announcementService.tags()) } } } @@ -154,16 +144,49 @@ private val authHeaderParameter = Parameter( examples = mapOf("Bearer authentication" to Parameter.Example("Bearer abc123")), ) -private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") - parameters = listOf(authHeaderParameter) + get = GetInfo.builder { + description("Get a page of announcements") + summary("Get announcements") + parameters( + Parameter( + name = "offset", + `in` = Parameter.Location.query, + schema = TypeDefinition.INT, + description = "The offset of the announcements", + required = false, + ), + Parameter( + name = "count", + `in` = Parameter.Location.query, + schema = TypeDefinition.INT, + description = "The count of the announcements", + required = false, + ), + Parameter( + name = "tag", + `in` = Parameter.Location.query, + schema = TypeDefinition.INT, + description = "The tag IDs to filter the announcements by", + required = false, + ), + ) + response { + responseCode(HttpStatusCode.OK) + mediaTypes("application/json") + description("The announcements") + responseType>() + } + } post = PostInfo.builder { description("Create a new announcement") summary("Create announcement") + parameters(authHeaderParameter) request { - requestType() + requestType() description("The new announcement") } response { @@ -175,37 +198,32 @@ private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRout } } -private fun Route.installLatestAnnouncementRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsLatestRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") get = GetInfo.builder { description("Get the latest announcement") summary("Get latest announcement") + parameters( + Parameter( + name = "tag", + `in` = Parameter.Location.query, + schema = TypeDefinition.INT, + description = "The tag IDs to filter the latest announcements by", + required = false, + ), + ) response { responseCode(HttpStatusCode.OK) mediaTypes("application/json") description("The latest announcement") - responseType() + responseType() } canRespond { - responseCode(HttpStatusCode.NotFound) - description("No announcement exists") - responseType() - } - } -} - -private fun Route.installLatestAnnouncementIdRouteDocumentation() = installNotarizedRoute { - tags = setOf("Announcements") - - get = GetInfo.builder { - description("Get the id of the latest announcement") - summary("Get id of latest announcement") - response { responseCode(HttpStatusCode.OK) mediaTypes("application/json") - description("The id of the latest announcement") - responseType() + description("The latest announcements") + responseType>() } canRespond { responseCode(HttpStatusCode.NotFound) @@ -215,65 +233,42 @@ private fun Route.installLatestAnnouncementIdRouteDocumentation() = installNotar } } -private fun Route.installChannelAnnouncementsRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsLatestIdRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") - parameters = listOf( - Parameter( - name = "channel", - `in` = Parameter.Location.path, - schema = TypeDefinition.STRING, - description = "The channel to get the announcements from", - required = true, - ), - ) - get = GetInfo.builder { - description("Get the announcements from a channel") - summary("Get announcements from channel") + description("Get the ID of the latest announcement") + summary("Get ID of latest announcement") + parameters( + Parameter( + name = "tag", + `in` = Parameter.Location.query, + schema = TypeDefinition.INT, + description = "The tag IDs to filter the latest announcements by", + required = false, + ), + ) response { responseCode(HttpStatusCode.OK) mediaTypes("application/json") - description("The announcements in the channel") - responseType>() + description("The ID of the latest announcement") + responseType() } - } -} - -private fun Route.installAnnouncementArchiveRouteDocumentation() = installNotarizedRoute { - tags = setOf("Announcements") - - parameters = listOf( - Parameter( - name = "id", - `in` = Parameter.Location.path, - schema = TypeDefinition.INT, - description = "The id of the announcement to archive", - required = true, - ), - Parameter( - name = "archivedAt", - `in` = Parameter.Location.query, - schema = TypeDefinition.STRING, - description = "The date and time the announcement to be archived", - required = false, - ), - authHeaderParameter, - ) - - post = PostInfo.builder { - description("Archive an announcement") - summary("Archive announcement") - response { - description("The announcement is archived") + canRespond { responseCode(HttpStatusCode.OK) + mediaTypes("application/json") + description("The IDs of the latest announcements") + responseType>() + } + canRespond { + responseCode(HttpStatusCode.NotFound) + description("No announcement exists") responseType() } - canRespondUnauthorized() } } -private fun Route.installAnnouncementUnarchiveRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsIdRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") parameters = listOf( @@ -281,43 +276,32 @@ private fun Route.installAnnouncementUnarchiveRouteDocumentation() = installNota name = "id", `in` = Parameter.Location.path, schema = TypeDefinition.INT, - description = "The id of the announcement to unarchive", + description = "The ID of the announcement to update", required = true, ), authHeaderParameter, ) - post = PostInfo.builder { - description("Unarchive an announcement") - summary("Unarchive announcement") + get = GetInfo.builder { + description("Get an announcement") + summary("Get announcement") response { - description("The announcement is unarchived") + description("The announcement") responseCode(HttpStatusCode.OK) + responseType() + } + canRespond { + responseCode(HttpStatusCode.NotFound) + description("The announcement does not exist") responseType() } - canRespondUnauthorized() } -} - -private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRoute { - tags = setOf("Announcements") - - parameters = listOf( - Parameter( - name = "id", - `in` = Parameter.Location.path, - schema = TypeDefinition.INT, - description = "The id of the announcement to update", - required = true, - ), - authHeaderParameter, - ) patch = PatchInfo.builder { description("Update an announcement") summary("Update announcement") request { - requestType() + requestType() description("The new announcement") } response { @@ -340,77 +324,63 @@ private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRo } } -private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute { - tags = setOf("Announcements") - - get = GetInfo.builder { - description("Get the announcements") - summary("Get announcements") - response { - responseCode(HttpStatusCode.OK) - mediaTypes("application/json") - description("The announcements") - responseType>() - } - } -} - -private fun Route.installLatestChannelAnnouncementRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsArchiveRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") parameters = listOf( Parameter( - name = "channel", + name = "id", `in` = Parameter.Location.path, - schema = TypeDefinition.STRING, - description = "The channel to get the latest announcement from", + schema = TypeDefinition.INT, + description = "The ID of the announcement to archive", required = true, ), + authHeaderParameter, ) - get = GetInfo.builder { - description("Get the latest announcement from a channel") - summary("Get latest channel announcement") + post = PostInfo.builder { + description("Archive an announcement") + summary("Archive announcement") + parameters( + Parameter( + name = "archivedAt", + `in` = Parameter.Location.query, + schema = TypeDefinition.STRING, + description = "The date and time the announcement to be archived", + required = false, + ), + ) response { + description("The announcement is archived") responseCode(HttpStatusCode.OK) - mediaTypes("application/json") - description("The latest announcement in the channel") - responseType() + responseType() } - canRespond { - responseCode(HttpStatusCode.NotFound) - description("The channel does not exist") + canRespondUnauthorized() + } + + delete = DeleteInfo.builder { + description("Unarchive an announcement") + summary("Unarchive announcement") + response { + description("The announcement is unarchived") + responseCode(HttpStatusCode.OK) responseType() } + canRespondUnauthorized() } } -private fun Route.installLatestChannelAnnouncementIdRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsTagsRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") - parameters = listOf( - Parameter( - name = "channel", - `in` = Parameter.Location.path, - schema = TypeDefinition.STRING, - description = "The channel to get the latest announcement id from", - required = true, - ), - ) - get = GetInfo.builder { - description("Get the id of the latest announcement from a channel") - summary("Get id of latest announcement from channel") + description("Get all announcement tags") + summary("Get announcement tags") response { responseCode(HttpStatusCode.OK) mediaTypes("application/json") - description("The id of the latest announcement from the channel") - responseType() - } - canRespond { - responseCode(HttpStatusCode.NotFound) - description("The channel does not exist") - responseType() + description("The announcement tags") + responseType>() } } } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt b/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt index 2296fee5..862a4d97 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt @@ -115,7 +115,7 @@ private fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute { description("The rate limit of the backend") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } @@ -144,7 +144,7 @@ private fun Route.installTeamRouteDocumentation() = installNotarizedRoute { description("The list of team members") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType>() + responseType>() } } } @@ -195,7 +195,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute { description("The authorization token") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } canRespondUnauthorized() } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt b/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt index 811f3a93..94851375 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt @@ -1,9 +1,9 @@ package app.revanced.api.configuration.routes import app.revanced.api.configuration.installNotarizedRoute -import app.revanced.api.configuration.schema.APIManagerAsset -import app.revanced.api.configuration.schema.APIRelease -import app.revanced.api.configuration.schema.APIReleaseVersion +import app.revanced.api.configuration.schema.ApiManagerAsset +import app.revanced.api.configuration.schema.ApiRelease +import app.revanced.api.configuration.schema.ApiReleaseVersion import app.revanced.api.configuration.services.ManagerService import io.bkbn.kompendium.core.metadata.GetInfo import io.ktor.http.* @@ -53,7 +53,7 @@ private fun Route.installManagerRouteDocumentation(deprecated: Boolean) = instal description("The latest manager release") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType>() + responseType>() } } } @@ -69,7 +69,7 @@ private fun Route.installManagerVersionRouteDocumentation(deprecated: Boolean) = description("The current manager release version") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt b/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt index efe7e100..7932f0ba 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt @@ -2,10 +2,10 @@ package app.revanced.api.configuration.routes import app.revanced.api.configuration.installCache import app.revanced.api.configuration.installNotarizedRoute -import app.revanced.api.configuration.schema.APIAssetPublicKeys -import app.revanced.api.configuration.schema.APIPatchesAsset -import app.revanced.api.configuration.schema.APIRelease -import app.revanced.api.configuration.schema.APIReleaseVersion +import app.revanced.api.configuration.schema.ApiAssetPublicKeys +import app.revanced.api.configuration.schema.ApiPatchesAsset +import app.revanced.api.configuration.schema.ApiRelease +import app.revanced.api.configuration.schema.ApiReleaseVersion import app.revanced.api.configuration.services.PatchesService import io.bkbn.kompendium.core.metadata.GetInfo import io.ktor.http.* @@ -78,7 +78,7 @@ private fun Route.installPatchesRouteDocumentation(deprecated: Boolean) = instal description("The current patches release") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType>() + responseType>() } } } @@ -94,7 +94,7 @@ private fun Route.installPatchesVersionRouteDocumentation(deprecated: Boolean) = description("The current patches release version") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } @@ -126,7 +126,7 @@ private fun Route.installPatchesPublicKeyRouteDocumentation(deprecated: Boolean) description("The public keys") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } diff --git a/src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt b/src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt index 6e749c1a..1ff79796 100644 --- a/src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt +++ b/src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt @@ -3,44 +3,44 @@ package app.revanced.api.configuration.schema import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable -interface APIUser { +interface ApiUser { val name: String val avatarUrl: String val url: String } @Serializable -class APIMember( +class ApiMember( override val name: String, override val avatarUrl: String, override val url: String, val bio: String?, - val gpgKey: APIGpgKey?, -) : APIUser + val gpgKey: ApiGpgKey?, +) : ApiUser @Serializable -class APIGpgKey( +class ApiGpgKey( val id: String, val url: String, ) @Serializable -class APIContributor( +class ApiContributor( override val name: String, override val avatarUrl: String, override val url: String, val contributions: Int, -) : APIUser +) : ApiUser @Serializable class APIContributable( val name: String, // Using a list instead of a set because set semantics are unnecessary here. - val contributors: List, + val contributors: List, ) @Serializable -class APIRelease( +class ApiRelease( val version: String, val createdAt: LocalDateTime, val description: String, @@ -49,74 +49,82 @@ class APIRelease( ) @Serializable -class APIManagerAsset( +class ApiManagerAsset( val downloadUrl: String, ) @Serializable -class APIPatchesAsset( +class ApiPatchesAsset( val downloadUrl: String, val signatureDownloadUrl: String, // TODO: Remove this eventually when integrations are merged into patches. - val name: APIAssetName, + val name: ApiAssetName, ) @Serializable -enum class APIAssetName { +enum class ApiAssetName { PATCHES, INTEGRATION, } @Serializable -class APIReleaseVersion( +class ApiReleaseVersion( val version: String, ) @Serializable -class APIAnnouncement( +class ApiAnnouncement( val author: String? = null, val title: String, val content: String? = null, // Using a list instead of a set because set semantics are unnecessary here. - val attachmentUrls: List = emptyList(), - val channel: String? = null, + val attachments: List = emptyList(), + // Using a list instead of a set because set semantics are unnecessary here. + val tags: List = emptyList(), val archivedAt: LocalDateTime? = null, val level: Int = 0, ) @Serializable -class APIResponseAnnouncement( +class ApiResponseAnnouncement( val id: Int, val author: String? = null, val title: String, val content: String? = null, // Using a list instead of a set because set semantics are unnecessary here. - val attachmentUrls: List = emptyList(), - val channel: String? = null, + val attachments: List = emptyList(), + // Using a list instead of a set because set semantics are unnecessary here. + val tags: List = emptyList(), val createdAt: LocalDateTime, val archivedAt: LocalDateTime? = null, val level: Int = 0, ) @Serializable -class APIResponseAnnouncementId( +class ApiResponseAnnouncementId( val id: Int, ) @Serializable -class APIAnnouncementArchivedAt( +class ApiAnnouncementArchivedAt( val archivedAt: LocalDateTime, ) @Serializable -class APIRateLimit( +class ApiAnnouncementTag( + val id: Int, + val name: String, +) + +@Serializable +class ApiRateLimit( val limit: Int, val remaining: Int, val reset: LocalDateTime, ) @Serializable -class APIAssetPublicKeys( +class ApiAssetPublicKeys( val patchesPublicKey: String, val integrationsPublicKey: String, ) @@ -174,4 +182,4 @@ class APIAbout( } @Serializable -class APIToken(val token: String) +class ApiToken(val token: String) diff --git a/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt b/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt index 909cff8a..bbc69727 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt @@ -1,35 +1,33 @@ package app.revanced.api.configuration.services import app.revanced.api.configuration.repository.AnnouncementRepository -import app.revanced.api.configuration.schema.APIAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncementId +import app.revanced.api.configuration.schema.ApiAnnouncement import kotlinx.datetime.LocalDateTime internal class AnnouncementService( private val announcementRepository: AnnouncementRepository, ) { - fun latestId(channel: String): APIResponseAnnouncementId? = announcementRepository.latestId(channel) - fun latestId(): APIResponseAnnouncementId? = announcementRepository.latestId() - - fun latest(channel: String) = announcementRepository.latest(channel) - fun latest() = announcementRepository.latest() - - suspend fun all(channel: String) = announcementRepository.all(channel) - suspend fun all() = announcementRepository.all() - - suspend fun new(new: APIAnnouncement) { - announcementRepository.new(new) - } - suspend fun archive(id: Int, archivedAt: LocalDateTime?) { - announcementRepository.archive(id, archivedAt) - } - suspend fun unarchive(id: Int) { - announcementRepository.unarchive(id) - } - suspend fun update(id: Int, new: APIAnnouncement) { - announcementRepository.update(id, new) - } - suspend fun delete(id: Int) { - announcementRepository.delete(id) - } + suspend fun latest(tags: Set) = announcementRepository.latest(tags) + + suspend fun latest() = announcementRepository.latest() + + fun latestId(tags: Set) = announcementRepository.latestId(tags) + + fun latestId() = announcementRepository.latestId() + + suspend fun paged(offset: Int, limit: Int, tags: Set?) = announcementRepository.paged(offset, limit, tags) + + suspend fun get(id: Int) = announcementRepository.get(id) + + suspend fun update(id: Int, new: ApiAnnouncement) = announcementRepository.update(id, new) + + suspend fun delete(id: Int) = announcementRepository.delete(id) + + suspend fun new(new: ApiAnnouncement) = announcementRepository.new(new) + + suspend fun archive(id: Int, archivedAt: LocalDateTime?) = announcementRepository.archive(id, archivedAt) + + suspend fun unarchive(id: Int) = announcementRepository.unarchive(id) + + suspend fun tags() = announcementRepository.tags() } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt b/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt index 5dc9f432..368c3b65 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt @@ -21,7 +21,7 @@ internal class ApiService( APIContributable( it, backendRepository.contributors(configurationRepository.organization, it).map { - APIContributor(it.name, it.avatarUrl, it.url, it.contributions) + ApiContributor(it.name, it.avatarUrl, it.url, it.contributions) }, ) } @@ -29,13 +29,13 @@ internal class ApiService( }.awaitAll() suspend fun team() = backendRepository.members(configurationRepository.organization).map { member -> - APIMember( + ApiMember( member.name, member.avatarUrl, member.url, member.bio, if (member.gpgKeys.ids.isNotEmpty()) { - APIGpgKey( + ApiGpgKey( // Must choose one of the GPG keys, because it does not make sense to have multiple GPG keys for the API. member.gpgKeys.ids.first(), member.gpgKeys.url, @@ -47,6 +47,6 @@ internal class ApiService( } suspend fun rateLimit() = backendRepository.rateLimit()?.let { - APIRateLimit(it.limit, it.remaining, it.reset) + ApiRateLimit(it.limit, it.remaining, it.reset) } } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt b/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt index f5bbb2b6..a3150b1c 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt @@ -1,6 +1,6 @@ package app.revanced.api.configuration.services -import app.revanced.api.configuration.schema.APIToken +import app.revanced.api.configuration.schema.ApiToken import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import io.ktor.server.auth.* @@ -43,7 +43,7 @@ internal class AuthenticationService private constructor( } } - fun newToken() = APIToken( + fun newToken() = ApiToken( JWT.create() .withIssuer(issuer) .withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES)) diff --git a/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt b/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt index 17b06650..d844684f 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt @@ -9,17 +9,17 @@ internal class ManagerService( private val backendRepository: BackendRepository, private val configurationRepository: ConfigurationRepository, ) { - suspend fun latestRelease(): APIRelease { + suspend fun latestRelease(): ApiRelease { val managerRelease = backendRepository.release( configurationRepository.organization, configurationRepository.manager.repository, ) - val managerAsset = APIManagerAsset( + val managerAsset = ApiManagerAsset( managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl, ) - return APIRelease( + return ApiRelease( managerRelease.tag, managerRelease.createdAt, managerRelease.releaseNote, @@ -27,12 +27,12 @@ internal class ManagerService( ) } - suspend fun latestVersion(): APIReleaseVersion { + suspend fun latestVersion(): ApiReleaseVersion { val managerRelease = backendRepository.release( configurationRepository.organization, configurationRepository.manager.repository, ) - return APIReleaseVersion(managerRelease.tag) + return ApiReleaseVersion(managerRelease.tag) } } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt b/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt index 6a70041a..235536f7 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt @@ -17,7 +17,7 @@ internal class PatchesService( private val backendRepository: BackendRepository, private val configurationRepository: ConfigurationRepository, ) { - suspend fun latestRelease(): APIRelease { + suspend fun latestRelease(): ApiRelease { val patchesRelease = backendRepository.release( configurationRepository.organization, configurationRepository.patches.repository, @@ -30,8 +30,8 @@ internal class PatchesService( fun ConfigurationRepository.SignedAssetConfiguration.asset( release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease, - assetName: APIAssetName, - ) = APIPatchesAsset( + assetName: ApiAssetName, + ) = ApiPatchesAsset( release.assets.first(assetRegex).downloadUrl, release.assets.first(signatureAssetRegex).downloadUrl, assetName, @@ -39,14 +39,14 @@ internal class PatchesService( val patchesAsset = configurationRepository.patches.asset( patchesRelease, - APIAssetName.PATCHES, + ApiAssetName.PATCHES, ) val integrationsAsset = configurationRepository.integrations.asset( integrationsRelease, - APIAssetName.INTEGRATION, + ApiAssetName.INTEGRATION, ) - return APIRelease( + return ApiRelease( patchesRelease.tag, patchesRelease.createdAt, patchesRelease.releaseNote, @@ -54,13 +54,13 @@ internal class PatchesService( ) } - suspend fun latestVersion(): APIReleaseVersion { + suspend fun latestVersion(): ApiReleaseVersion { val patchesRelease = backendRepository.release( configurationRepository.organization, configurationRepository.patches.repository, ) - return APIReleaseVersion(patchesRelease.tag) + return ApiReleaseVersion(patchesRelease.tag) } private val patchesListCache = Caffeine @@ -111,12 +111,12 @@ internal class PatchesService( } } - fun publicKeys(): APIAssetPublicKeys { + fun publicKeys(): ApiAssetPublicKeys { fun readPublicKey( getSignedAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.SignedAssetConfiguration, ) = configurationRepository.getSignedAssetConfiguration().publicKeyFile.readText() - return APIAssetPublicKeys( + return ApiAssetPublicKeys( readPublicKey { patches }, readPublicKey { integrations }, ) diff --git a/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt b/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt new file mode 100644 index 00000000..002774bc --- /dev/null +++ b/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt @@ -0,0 +1,176 @@ +package app.revanced.api.configuration.services + +import app.revanced.api.configuration.repository.AnnouncementRepository +import app.revanced.api.configuration.schema.ApiAnnouncement +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.toKotlinLocalDateTime +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.TransactionManager +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.assertNull +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private object AnnouncementServiceTest { + private lateinit var announcementService: AnnouncementService + + @JvmStatic + @BeforeAll + fun setUp() { + TransactionManager.defaultDatabase = + Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false") + + announcementService = AnnouncementService(AnnouncementRepository()) + } + + @BeforeEach + fun clear() { + runBlocking { + while (true) { + val latestId = announcementService.latestId() ?: break + announcementService.delete(latestId.id) + } + } + } + + @Test + fun `can do basic crud`(): Unit = runBlocking { + announcementService.new(ApiAnnouncement(title = "title")) + + val latestId = announcementService.latestId()!!.id + + announcementService.update(latestId, ApiAnnouncement(title = "new title")) + assert(announcementService.get(latestId)?.title == "new title") + + announcementService.delete(latestId) + assertNull(announcementService.get(latestId)) + assertNull(announcementService.latestId()) + } + + @Test + fun `archiving works properly`() = runBlocking { + announcementService.new(ApiAnnouncement(title = "title")) + val latestId = announcementService.latestId()!!.id + + announcementService.archive(latestId, LocalDateTime.now().toKotlinLocalDateTime()) + assertNotNull(announcementService.get(latestId)?.archivedAt) + + announcementService.unarchive(latestId) + assertNull(announcementService.get(latestId)?.archivedAt) + } + + @Test + fun `latest works properly`() = runBlocking { + announcementService.new(ApiAnnouncement(title = "title")) + announcementService.new(ApiAnnouncement(title = "title2")) + + var latest = announcementService.latest() + assert(latest?.title == "title2") + + announcementService.delete(latest!!.id) + + latest = announcementService.latest() + assert(latest?.title == "title") + + announcementService.delete(latest!!.id) + assertNull(announcementService.latest()) + + announcementService.new(ApiAnnouncement(title = "1", tags = listOf("tag1", "tag2"))) + announcementService.new(ApiAnnouncement(title = "2", tags = listOf("tag1", "tag3"))) + announcementService.new(ApiAnnouncement(title = "3", tags = listOf("tag1", "tag4"))) + + val tag2 = announcementService.tags().find { it.name == "tag2" }!!.id + assert(announcementService.latest(setOf(tag2)).first().title == "1") + + val tag3 = announcementService.tags().find { it.name == "tag3" }!!.id + assert(announcementService.latest(setOf(tag3)).last().title == "2") + + val tag1and3 = + announcementService.tags().filter { it.name == "tag1" || it.name == "tag3" }.map { it.id }.toSet() + val announcement2and3 = announcementService.latest(tag1and3) + assert(announcement2and3.size == 2) + assert(announcement2and3.any { it.title == "2" }) + assert(announcement2and3.any { it.title == "3" }) + + announcementService.delete(announcementService.latestId()!!.id) + assert(announcementService.latest(tag1and3).first().title == "2") + + announcementService.delete(announcementService.latestId()!!.id) + assert(announcementService.latest(tag1and3).first().title == "1") + + announcementService.delete(announcementService.latestId()!!.id) + assert(announcementService.latest(tag1and3).isEmpty()) + assert(announcementService.tags().isEmpty()) + } + + @Test + fun `tags work properly`() = runBlocking { + announcementService.new(ApiAnnouncement(title = "title", tags = listOf("tag1", "tag2"))) + announcementService.new(ApiAnnouncement(title = "title2", tags = listOf("tag1", "tag3"))) + + val tags = announcementService.tags() + assertEquals(3, tags.size) + assert(tags.any { it.name == "tag1" }) + assert(tags.any { it.name == "tag2" }) + assert(tags.any { it.name == "tag3" }) + + announcementService.delete(announcementService.latestId()!!.id) + assertEquals(2, announcementService.tags().size) + + announcementService.update( + announcementService.latestId()!!.id, + ApiAnnouncement(title = "title", tags = listOf("tag1", "tag3")), + ) + + assertEquals(2, announcementService.tags().size) + assert(announcementService.tags().any { it.name == "tag3" }) + } + + @Test + fun `attachments work properly`() = runBlocking { + announcementService.new(ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment2"))) + + val latestAnnouncement = announcementService.latest()!! + val latestId = latestAnnouncement.id + + val attachments = latestAnnouncement.attachments + assertEquals(2, attachments.size) + assert(attachments.any { it == "attachment1" }) + assert(attachments.any { it == "attachment2" }) + + announcementService.update( + latestId, + ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment3")), + ) + assert(announcementService.get(latestId)!!.attachments.any { it == "attachment3" }) + } + + @Test + fun `paging works correctly`() = runBlocking { + repeat(10) { + announcementService.new(ApiAnnouncement(title = "title$it")) + } + + val announcements = announcementService.paged(0, 5, null) + assertEquals(5, announcements.size) + assertEquals("title9", announcements.first().title) + + val announcements2 = announcementService.paged(5, 5, null) + assertEquals(5, announcements2.size) + assertEquals("title4", announcements2.first().title) + + announcements2.map { it.id }.forEach { id -> + announcementService.update( + id, + ApiAnnouncement(title = "title$id", tags = (1..id).map { "tag$it" }), + ) + } + + val tags = announcementService.tags() + assertEquals(5, tags.size) + + val announcements3 = announcementService.paged(0, 5, setOf(tags.first().id)) + assertEquals(5, announcements3.size) + } +}