diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e90e05e..f290a7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,4 +26,6 @@ jobs: if: matrix.scala == '2.13.5' run: sbt ++${{ matrix.scala }} scalafmtCheckAll - name: Run tests + env: + DISSONANCE_IT_TOKEN: ${{secrets.DISSONANCE_IT_TOKEN}} run: sbt ++${{ matrix.scala }} clean coverage test diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 70c8e6f..df251da 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,6 +14,8 @@ jobs: with: java-version: 11 - name: Generate code coverage + env: + DISSONANCE_IT_TOKEN: ${{secrets.DISSONANCE_IT_TOKEN}} run: sbt ++2.13.5 'project core' clean coverage test coverageReport - name: Upload coverage uses: codecov/codecov-action@v1 diff --git a/build.sbt b/build.sbt index 574b83f..48d8114 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,7 @@ import Dependencies._ -lazy val dissonance = project.in(file(".")) +lazy val dissonance = project + .in(file(".")) .settings(commonSettings, releaseSettings, publish / skip := true) .aggregate(core) @@ -13,6 +14,7 @@ lazy val core = project fork := true, // Fork to separate process connectInput := true, // Connects stdin to sbt during forked runs outputStrategy := Some(StdoutOutput), // Get rid of output prefix + testFrameworks += new TestFramework("weaver.framework.CatsEffect") ) lazy val docs = project @@ -38,7 +40,7 @@ lazy val commonSettings = Seq( case _ => Nil }) ++ dependencies, addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), - addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.11.3" cross CrossVersion.full), + addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.11.3" cross CrossVersion.full) ) lazy val releaseSettings = Seq( @@ -61,4 +63,4 @@ lazy val releaseSettings = Seq( ) ) -Global / onChangedBuildSource := ReloadOnSourceChanges \ No newline at end of file +Global / onChangedBuildSource := ReloadOnSourceChanges diff --git a/core/src/main/scala/dissonance/Discord.scala b/core/src/main/scala/dissonance/Discord.scala index 25a075c..1073123 100644 --- a/core/src/main/scala/dissonance/Discord.scala +++ b/core/src/main/scala/dissonance/Discord.scala @@ -3,8 +3,8 @@ package dissonance import java.io.IOException import cats.Applicative +import cats.effect.std.Queue import cats.effect._ -import cats.effect.concurrent._ import cats.syntax.all._ import dissonance.Discord._ import dissonance.data.ControlMessage._ @@ -12,7 +12,6 @@ import dissonance.data._ import dissonance.data.events.Ready import dissonance.utils._ import fs2.Stream -import fs2.concurrent.Queue import io.circe.Json import io.circe.parser._ import io.circe.syntax._ @@ -24,10 +23,11 @@ import org.http4s.client.jdkhttpclient._ import org.http4s.headers._ import org.http4s.implicits._ import org.http4s.{headers => _, _} +import org.typelevel.ci.CIString import scala.concurrent.duration._ -class Discord[F[_]: Concurrent](token: String, val httpClient: Client[F], wsClient: WSClient[F])(implicit cs: ContextShift[F], t: Timer[F]) { +class Discord[F[_]: Async](token: String, val httpClient: Client[F], wsClient: WSClient[F])(implicit t: Temporal[F]) { type SequenceNumber = Ref[F, Option[Int]] type SessionId = Ref[F, Option[String]] type Acks = Queue[F, Unit] @@ -103,7 +103,7 @@ class Discord[F[_]: Concurrent](token: String, val httpClient: Client[F], wsClie case Hello(intervalDuration) => interval.complete(intervalDuration) >> identifyOrResume(state.sessionId, state.sequenceNumber, shard, intents).flatMap(connection.send).as(Result(None)) case HeartBeatAck => - state.acks.enqueue1(()).as(Result(None)) + state.acks.offer(()).as(Result(None)) case Heartbeat(d) => putStrLn(s"Heartbeat received: $d").as(Result(None)) case Reconnect => @@ -121,7 +121,7 @@ class Discord[F[_]: Concurrent](token: String, val httpClient: Client[F], wsClie } private def connection(uri: Uri): Stream[F, WSConnectionHighLevel[F]] = - Stream.resource(wsClient.connectHighLevel(WSRequest(uri, Headers.of(headers(token))))) + Stream.resource(wsClient.connectHighLevel(WSRequest(uri, Headers(headers(token))))) private def heartbeatInterval: Stream[F, HeartbeatInterval] = Stream.eval(Deferred[F, FiniteDuration]) @@ -138,7 +138,7 @@ class Discord[F[_]: Concurrent](token: String, val httpClient: Client[F], wsClie val heartbeats = Stream.eval(sendHeartbeat) ++ Stream.repeatEval(sendHeartbeat).metered(interval) // TODO: Something besides true, false - (heartbeats.as(true) merge acks.dequeue.as(false)).zipWithPrevious.flatMap { + (heartbeats.as(true) merge Stream.emit(acks.take.as(false))).zipWithPrevious.flatMap { case (Some(true), true) => Stream.raiseError[F](Errors.NoHeartbeatAck) case _ => Stream.emit(()) } @@ -162,11 +162,15 @@ class Discord[F[_]: Concurrent](token: String, val httpClient: Client[F], wsClie } object Discord { - def make[F[_]: ConcurrentEffect](token: String)(implicit cs: ContextShift[F], t: Timer[F]): Resource[F, Discord[F]] = - Resource.eval(utils.javaClient.map(javaClient => new Discord(token, JdkHttpClient[F](javaClient), JdkWSClient[F](javaClient)))) + def make[F[_]: Async](token: String): Resource[F, Discord[F]] = + for { + javaClient <- Resource.eval(utils.javaClient) + javaHttpClient <- JdkHttpClient[F](javaClient) + javaWsClient <- JdkWSClient[F](javaClient) + } yield new Discord(token, javaHttpClient, javaWsClient) val apiEndpoint = uri"https://discordapp.com/api/v8" - def headers(token: String): Authorization = Authorization(Credentials.Token("Bot".ci, token)) + def headers(token: String): Authorization = Authorization(Credentials.Token(CIString("Bot"), token)) sealed trait EventResult extends Product with Serializable { val terminate: Boolean } case class Result(event: Option[Event]) extends EventResult { val terminate = false } diff --git a/core/src/main/scala/dissonance/DiscordClient.scala b/core/src/main/scala/dissonance/DiscordClient.scala index bd81633..dd5c8e2 100644 --- a/core/src/main/scala/dissonance/DiscordClient.scala +++ b/core/src/main/scala/dissonance/DiscordClient.scala @@ -20,11 +20,11 @@ import org.http4s.client.jdkhttpclient.JdkHttpClient import org.http4s.multipart.{Multipart, Part} import org.http4s.{Request, Status, Uri} -class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: ContextShift[F]) { +class DiscordClient[F[_]: Async](token: String, client: Client[F]) { def sendMessage(message: String, channelId: Snowflake, tts: Boolean = false): F[Message] = client - .expect[Message]( + .fetchAs[Message]( Request[F]() .withMethod(POST) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages")) @@ -35,7 +35,7 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C "tts" -> tts.asJson ) ) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def deleteMessage(channelId: Snowflake, messageId: Snowflake): F[Unit] = @@ -44,13 +44,13 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C Request[F]() .withMethod(DELETE) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages/$messageId")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) .handleErrorWith(_ => Applicative[F].unit) // Throws: java.io.IOException: unexpected content length header with 204 response def sendEmbed(embed: Embed, channelId: Snowflake): F[Message] = client - .expect[Message]( + .fetchAs[Message]( Request[F]() .withMethod(POST) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages")) @@ -58,7 +58,7 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C // TODO Case class here Json.obj("embed" -> embed.asJson) ) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def sendInteractionResponse(interactionResponse: InteractionResponse, interactionId: Snowflake, interactionToken: String): F[Unit] = @@ -71,32 +71,32 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C ) .handleErrorWith(_ => Applicative[F].unit) // Throws: java.io.IOException: unexpected content length header with 204 response - def sendEmbedWithFileImage(embed: Embed, file: File, channelId: Snowflake, blocker: Blocker): F[Message] = { + def sendEmbedWithFileImage(embed: Embed, file: File, channelId: Snowflake): F[Message] = { val multipart = Multipart[F]( Vector( - Part.fileData[F]("file", file, blocker), + Part.fileData[F]("file", file), Part.formData("payload_json", Json.obj("embed" -> embed.withImage(Image(Some(Uri.unsafeFromString(s"attachment://${file.getName}")), None, None, None)).asJson).noSpaces) ) ) client - .expect[Message]( + .fetchAs[Message]( Request[F]() .withMethod(POST) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages")) .withEntity(multipart) - .withHeaders(headers(token) :: multipart.headers.toList: _*) + .putHeaders(multipart.headers.headers, headers(token)) ) } - def sendFile(file: File, channelId: Snowflake, blocker: Blocker): F[Message] = { - val multipart = Multipart[F](Vector(Part.fileData[F]("file", file, blocker))) + def sendFile(file: File, channelId: Snowflake): F[Message] = { + val multipart = Multipart[F](Vector(Part.fileData[F]("file", file))) client - .expect[Message]( + .fetchAs[Message]( Request[F]() .withMethod(POST) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages")) .withEntity(multipart) - .withHeaders(headers(token) :: multipart.headers.toList: _*) + .putHeaders(multipart.headers.headers, headers(token)) ) } @@ -106,14 +106,14 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C Request[F]() .withMethod(PUT) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages/$messageId/reactions/$emoji/@me")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) .handleErrorWith(_ => Applicative[F].unit) // Throws: java.io.IOException: unexpected content length header with 204 response def addEmoji(guildId: Snowflake, name: String, emojiData: Array[Byte], roles: List[Snowflake] = Nil): F[Emoji] = { val imageData = "data:;base64," + fs2.Stream.emits(emojiData).through(fs2.text.base64.encode).compile.foldMonoid client - .expect[Emoji]( + .fetchAs[Emoji]( Request[F]() .withMethod(POST) .withUri(apiEndpoint.addPath(s"guilds/$guildId/emojis")) @@ -125,31 +125,31 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C "roles" -> roles.asJson ) ) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) } def listEmojis(guildId: Snowflake): F[List[Emoji]] = client - .expect[List[Emoji]]( + .fetchAs[List[Emoji]]( Request[F]() .withMethod(GET) .withUri(apiEndpoint.addPath(s"guilds/$guildId/emojis")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def getChannelMessage(channelId: Snowflake, messageId: Snowflake): F[Message] = client - .expect[Message]( + .fetchAs[Message]( Request[F]() .withMethod(GET) .withUri(apiEndpoint.addPath(s"channels/$channelId/messages/$messageId")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def createWebhook(name: String, avatar: Option[ImageDataUri], channelId: Snowflake): F[Webhook] = client - .expect[Webhook]( + .fetchAs[Webhook]( Request[F]() .withMethod(POST) .withUri(apiEndpoint.addPath(s"channels/$channelId/webhooks")) @@ -159,39 +159,39 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C "avatar" -> avatar.asJson ) ) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def getChannelWebhooks(channelId: Snowflake): F[List[Webhook]] = client - .expect[List[Webhook]]( + .fetchAs[List[Webhook]]( Request[F]() .withMethod(GET) .withUri(apiEndpoint.addPath(s"channels/$channelId/webhooks")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def getGuildWebhooks(guildId: Snowflake): F[List[Webhook]] = client - .expect[List[Webhook]]( + .fetchAs[List[Webhook]]( Request[F]() .withMethod(GET) .withUri(apiEndpoint.addPath(s"guilds/$guildId/webhooks")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def getWebhook(webhookId: Snowflake): F[Webhook] = client - .expect[Webhook]( + .fetchAs[Webhook]( Request[F]() .withMethod(GET) .withUri(apiEndpoint.addPath(s"webhooks/$webhookId")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def getWebhookWithToken(webhookId: Snowflake, token: String): F[Webhook] = client - .expect[Webhook]( + .fetchAs[Webhook]( Request[F]() .withMethod(GET) .withUri(apiEndpoint.addPath(s"webhooks/$webhookId/$token")) @@ -199,7 +199,7 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C def modifyWebhook(webhookId: Snowflake, name: Option[String], avatar: Option[ImageDataUri], channelId: Option[Snowflake]): F[Webhook] = client - .expect[Webhook]( + .fetchAs[Webhook]( Request[F]() .withMethod(PATCH) .withUri(apiEndpoint.addPath(s"webhooks/$webhookId")) @@ -211,12 +211,12 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C "channel_id" -> channelId.asJson ) ) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def modifyWebhookWithToken(webhookId: Snowflake, name: Option[String], avatar: Option[ImageDataUri], channelId: Option[Snowflake], token: String): F[Webhook] = client - .expect[Webhook]( + .fetchAs[Webhook]( Request[F]() .withMethod(PATCH) .withUri(apiEndpoint.addPath(s"webhooks/$webhookId/$token")) @@ -236,7 +236,7 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C Request[F]() .withMethod(DELETE) .withUri(apiEndpoint.addPath(s"webhooks/$webhookId")) - .withHeaders(headers(token)) + .putHeaders(headers(token)) ) def deleteWebhookWithToken(webhookId: Snowflake, token: String): F[Status] = @@ -248,10 +248,16 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C ) def executeWebhookWithResponse(webhook: Webhook, webhookMessage: WebhookMessage): F[Message] = - client.expect[Message](createExecuteWebhookRequest(webhook, webhookMessage, wait = true)) + client + .fetchAs[Message]( + createExecuteWebhookRequest(webhook, webhookMessage, wait = true) + ) def executeWebhook(webhook: Webhook, webhookMessage: WebhookMessage): F[Status] = - client.status(createExecuteWebhookRequest(webhook, webhookMessage, wait = false)) + client + .status( + createExecuteWebhookRequest(webhook, webhookMessage, wait = false) + ) // TODO: Handle uploading files which requires multipart/form-data private def createExecuteWebhookRequest(webhook: Webhook, webhookMessage: WebhookMessage, wait: Boolean): Request[F] = @@ -263,14 +269,17 @@ class DiscordClient[F[_]: Sync](token: String, client: Client[F])(implicit cs: C .withQueryParam("wait", wait) ) .withEntity(webhookMessage) - .withHeaders(headers(token)) + .putHeaders(headers(token)) // TODO: Add Slack and Github Webhooks } object DiscordClient { - def make[F[_]: ConcurrentEffect](token: String)(implicit cs: ContextShift[F]): Resource[F, DiscordClient[F]] = - Resource.eval(utils.javaClient.map(javaClient => new DiscordClient(token, JdkHttpClient[F](javaClient)))) + def make[F[_]: Async](token: String): Resource[F, DiscordClient[F]] = + for { + javaHttpClient <- Resource.eval(utils.javaClient) + javaClient <- JdkHttpClient[F](javaHttpClient) + } yield new DiscordClient(token, javaClient) type AllowedMentions = Unit // TODO: Implement this diff --git a/core/src/main/scala/dissonance/utils.scala b/core/src/main/scala/dissonance/utils.scala index f82e98a..5a70e4f 100644 --- a/core/src/main/scala/dissonance/utils.scala +++ b/core/src/main/scala/dissonance/utils.scala @@ -3,17 +3,18 @@ package dissonance import java.net.http.HttpClient import cats.effect._ -import cats.syntax.flatMap._ +import cats.syntax.all._ import scala.concurrent.duration._ +import cats.effect.Temporal object utils { def putStrLn[F[_]: Sync](s: String): F[Unit] = Sync[F].delay(println(s)) - def fakeResource[F[_]: Sync: Timer](i: Int, duration: FiniteDuration) = + def fakeResource[F[_]: Async](i: Int, duration: FiniteDuration) = Resource.make { - putStrLn(s"Acquiring Resource $i...") >> Timer[F].sleep(duration) >> putStrLn(s"Acquired Resource $i") - } { _ => putStrLn(s"Releasing Resource $i...") >> Timer[F].sleep(duration) >> putStrLn(s"Released Resource $i") } + putStrLn(s"Acquiring Resource $i...") >> Temporal[F].sleep(duration) >> putStrLn(s"Acquired Resource $i") + } { _ => putStrLn(s"Releasing Resource $i...") >> Temporal[F].sleep(duration) >> putStrLn(s"Released Resource $i") } def javaClient[F[_]: Sync]: F[HttpClient] = Sync[F].delay { diff --git a/core/src/test/scala/dissonance/DiscordSpec.scala b/core/src/test/scala/dissonance/DiscordSpec.scala new file mode 100644 index 0000000..f53867a --- /dev/null +++ b/core/src/test/scala/dissonance/DiscordSpec.scala @@ -0,0 +1,106 @@ +package dissonance + +import cats.effect._ +import cats.effect.kernel.Deferred +import cats.effect.std.Queue +import cats.syntax.all._ +import dissonance.data._ +import dissonance.data.events.{MessageCreate, Ready} +import org.http4s.Uri +import weaver._ + +import scala.concurrent.duration._ + +object DiscordSpec extends IOSuite { + type EventQueue = Queue[IO, Event] + type SessionId = String + + override type Res = Discord[IO] + + override def sharedResource: Resource[IO, Res] = + for { + token <- Resource.eval( + ciris + .env("DISSONANCE_IT_TOKEN") + .secret + .load[IO] + ) + discord <- Discord.make(token.value) + } yield discord + + private val testChannelId: Snowflake = 834670988083855370L + + def withBackgroundProcessing(discord: Discord[IO])(runExpectation: (SessionId, EventQueue) => IO[Expectations]): IO[Expectations] = + Resource + .eval((Deferred[IO, SessionId], Queue.bounded[IO, Event](100)).tupled) + .use { case (sessionId, queue) => + val backgroundConsumer = discord + .subscribe(Shard.singleton, Intent.GuildMessages) + .evalMap { + case Ready(_, _, sid, _) => + sessionId.complete(sid) + case e => + queue.offer(e) + } + .compile + .drain + + backgroundConsumer.background.use { _ => + for { + sessionId <- sessionId.get + expectation <- runExpectation(sessionId, queue) + } yield expectation + } + } + + test("sendMessage ('ping') should create MessageCreate event") { discord => + withBackgroundProcessing(discord) { case (_, queue) => + val pingMessage = "ping" + for { + _ <- discord.client.sendMessage(pingMessage, testChannelId) + expectation <- queue.take + .map { + case MessageCreate(message) => + expect(message.content == pingMessage) + case e => + failure(s"Received $e instead of MessageCreate") + } + .timeout(10.seconds) + } yield expectation + } + } + + test("sendEmbed (simple) should create MessageCreate event with Embed") { discord => + withBackgroundProcessing(discord) { case (_, queue) => + val title = "Matt" + val description = "Radar Technician" + val thumbnailUrl = "https://media.giphy.com/media/tywn2kxZC91lK/giphy.gif" + val image = Image(Uri.fromString(thumbnailUrl).toOption, None, None, None) + val footer = Footer("*actually Kylo Ren", Uri.fromString("https://i.kym-cdn.com/entries/icons/original/000/025/003/benswoll.jpg").toOption, None) + val embeddedMessage = Embed.make + .withTitle(title) + .withDescription(description) + .withThumbnail(image) + .withFooter(footer) + + for { + _ <- discord.client.sendEmbed(embeddedMessage, testChannelId) + expectation <- queue.take + .map { + case MessageCreate(message) => + val actualEmbed = message.embeds.head + expect.all( + actualEmbed.title.contains(title), + actualEmbed.description.contains(description), + actualEmbed.thumbnail.flatMap(_.url.map(_.renderString)).contains(thumbnailUrl), + actualEmbed.footer.map(_.text).contains(footer.text) + ) + case e => + failure(s"Received $e instead of MessageCreate") + } + .timeout(10.seconds) + } yield expectation + } + } + +} diff --git a/core/src/test/scala/dissonance/TestUtils.scala b/core/src/test/scala/dissonance/TestUtils.scala index f6841c4..a86fe37 100644 --- a/core/src/test/scala/dissonance/TestUtils.scala +++ b/core/src/test/scala/dissonance/TestUtils.scala @@ -1,18 +1,16 @@ package dissonance -import scala.io.Source +import java.nio.file.Path + import cats.effect.IO -import cats.effect.ContextShift +import fs2.io.file.Files object TestUtils { - // TODO: Return Stream[IO, String] of lines from file - def readFileFromResource(path: String)(implicit contextShift: ContextShift[IO]): IO[List[String]] = { - val acquire = IO.shift *> IO(Source.fromURL(getClass.getResource(path))) - - acquire.bracket { in => - IO(in.getLines().toList) - } { in => - IO(in.close()).void - } + def readFileFromResource(path: String): fs2.Stream[IO, String] = { + Files[IO] + .readAll(Path.of(getClass.getResource(path).toURI), 4096) + .through(fs2.text.utf8Decode) + .through(fs2.text.lines) + .dropLastIf(_.isEmpty) } } diff --git a/core/src/test/scala/dissonance/model/DispatchSpec.scala b/core/src/test/scala/dissonance/model/DispatchSpec.scala index 7b86760..4a001d4 100644 --- a/core/src/test/scala/dissonance/model/DispatchSpec.scala +++ b/core/src/test/scala/dissonance/model/DispatchSpec.scala @@ -1,37 +1,34 @@ package dissonance.data -import cats.effect._ +import cats.syntax.all._ import dissonance.TestUtils._ import io.circe.parser._ -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.must.Matchers -import scala.concurrent.ExecutionContext +import weaver.SimpleIOSuite -class DispatchSpec extends AnyFlatSpec with Matchers { - implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) +object DispatchSpec extends SimpleIOSuite { - "dispatch events" should "parse correctly" in { - // TODO: Still need to generate the following events somehow, and probably more examples of the existing ones - // RESUMED - // GUILD_DELETE - // GUILD_INTEGRATIONS_UPDATE - // GUILD_MEMBER_ADD - // GUILD_MEMBER_REMOVE - // GUILD_MEMBER_UPDATE - // GUILD_MEMBERS_CHUNK - // INVITE_CREATE - // INVITE_DELETE - // MESSAGE_DELETE_BULK - // MESSAGE_REACTION_REMOVE_ALL - // MESSAGE_REACTION_REMOVE_EMOJI - // PRESENCE_UPDATE - // USER_UPDATE - // VOICE_SERVER_UPDATE - - val rawJson = readFileFromResource("/models/events.ndjson").unsafeRunSync() - - rawJson.foreach { event => - parse(event).flatMap(_.as[ControlMessage]) must matchPattern { case Right(_) => } - } + // TODO: Still need to generate the following events somehow, and probably more examples of the existing ones + // RESUMED + // GUILD_DELETE + // GUILD_INTEGRATIONS_UPDATE + // GUILD_MEMBER_ADD + // GUILD_MEMBER_REMOVE + // GUILD_MEMBER_UPDATE + // GUILD_MEMBERS_CHUNK + // INVITE_CREATE + // INVITE_DELETE + // MESSAGE_DELETE_BULK + // MESSAGE_REACTION_REMOVE_ALL + // MESSAGE_REACTION_REMOVE_EMOJI + // PRESENCE_UPDATE + // USER_UPDATE + // VOICE_SERVER_UPDATE + test("dispatch events should parse correctly") { + for { + rawJson <- readFileFromResource("/models/events.ndjson").compile.toList + expectations = rawJson.map { event => + expect(parse(event).flatMap(_.as[ControlMessage]).isRight) + } + } yield expectations.combineAll } } diff --git a/core/src/test/scala/dissonance/model/user/UserSpec.scala b/core/src/test/scala/dissonance/model/user/UserSpec.scala index d945782..d1ce524 100644 --- a/core/src/test/scala/dissonance/model/user/UserSpec.scala +++ b/core/src/test/scala/dissonance/model/user/UserSpec.scala @@ -1,19 +1,14 @@ package dissonance.data -import cats.effect._ import cats.syntax.all._ +import dissonance.TestUtils._ import dissonance.data.PremiumType.{None => _, _} import dissonance.data.UserRole._ -import dissonance.TestUtils._ import io.circe.parser._ -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.must.Matchers -import scala.concurrent.ExecutionContext +import weaver.SimpleIOSuite -class UserSpec extends AnyFlatSpec with Matchers { - implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) - - val baseUser = User( +object UserSpec extends SimpleIOSuite { + val baseUser: User = User( id = DiscordId(80351110224678912L), username = "Nelly", discriminator = "1337", @@ -29,24 +24,24 @@ class UserSpec extends AnyFlatSpec with Matchers { publicFlags = List(HouseBravery) ) - "a partial user json" should "be parsed correctly" in { - val rawJson = readFileFromResource("/models/partialUser.json").unsafeRunSync().mkString("\n") - - val expectedUser = baseUser - - parse(rawJson).flatMap(_.as[User]) mustBe Right(expectedUser) + test("a partial user json should be parsed correctly") { + for { + rawJson <- readFileFromResource("/models/partialUser.json").compile.toList.map(_.mkString("\n")) + expectedUser = baseUser + expectations = parse(rawJson).flatMap(_.as[User]).map(parsedUser => expect(parsedUser == expectedUser)) + } yield expectations.combineAll } - "a full user json" should "be parsed correctly" in { - val rawJson = readFileFromResource("/models/fullUser.json").unsafeRunSync().mkString("\n") - - val expectedUser = baseUser.copy( - bot = false.some, - system = false.some, - mfaEnabled = true.some, - locale = "en".some - ) - - parse(rawJson).flatMap(_.as[User]) mustBe Right(expectedUser) + test("a full user json should be parsed correctly") { + for { + rawJson <- readFileFromResource("/models/fullUser.json").compile.toList.map(_.mkString("\n")) + expectedUser = baseUser.copy( + bot = false.some, + system = false.some, + mfaEnabled = true.some, + locale = "en".some + ) + expectations = parse(rawJson).flatMap(_.as[User]).map(parsedUser => expect(parsedUser == expectedUser)) + } yield expectations.combineAll } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 9a56bc5..ee166d0 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,45 +3,53 @@ import sbt._ object Dependencies { object Versions { - val fs2 = "2.5.4" val cats = "2.6.0" + val catsEffect = "3.0.2" val circe = "0.13.0" - val http4s = "0.21.22" + val ciris = "2.0.0-RC2" + val enumeratum = "1.6.1" + val fs2 = "3.0.1" + val http4s = "1.0.0-M21" val newtype = "0.4.4" val refined = "0.9.24" val scalaTest = "3.2.8" - val catsEffect = "2.5.0" - val enumeratum = "1.6.1" - val websocketClient = "0.3.6" + val weaverTest = "0.7.1" + val websocketClient = "0.5.0-M4" } object Compile { - val fs2 = "co.fs2" %% "fs2-core" % Versions.fs2 val cats = "org.typelevel" %% "cats-core" % Versions.cats + val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect val circe = Seq("circe-core", "circe-parser", "circe-generic-extras").map("io.circe" %% _ % Versions.circe) + val enumeratum = Seq("enumeratum", "enumeratum-circe").map("com.beachape" %% _ % Versions.enumeratum) + val fs2 = "co.fs2" %% "fs2-core" % Versions.fs2 val http4s = "org.http4s" %% "http4s-circe" % Versions.http4s val newtype = "io.estatico" %% "newtype" % Versions.newtype val refined = "eu.timepit" %% "refined" % Versions.refined - val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect - val enumeratum = Seq("enumeratum", "enumeratum-circe").map("com.beachape" %% _ % Versions.enumeratum) val websocketClient = "org.http4s" %% "http4s-jdk-http-client" % Versions.websocketClient } object Test { - val scalaTest = "org.scalatest" %% "scalatest" % Versions.scalaTest % "test" + val Test = "test" + + val ciris = "is.cir" %% "ciris" % Versions.ciris % Test + val scalaTest = "org.scalatest" %% "scalatest" % Versions.scalaTest % Test + val weaverTest = "com.disneystreaming" %% "weaver-cats" % Versions.weaverTest % Test } import Compile._ import Test._ lazy val dependencies = Seq( - fs2, cats, + catsEffect, + ciris, + fs2, http4s, newtype, refined, scalaTest, - catsEffect, + weaverTest, websocketClient ) ++ enumeratum ++ circe } diff --git a/project/plugins.sbt b/project/plugins.sbt index 01023fc..973b738 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,3 +4,4 @@ addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.17") addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.20") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.27")