feat(account): implement account and challenge management services
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
plugins {
|
||||||
|
id("scala")
|
||||||
|
id("org.scoverage") version "8.1"
|
||||||
|
id("io.quarkus")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "de.nowchess"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
scala {
|
||||||
|
scalaVersion = versions["SCALA3"]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
scoverage {
|
||||||
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<ScalaCompile> {
|
||||||
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
val quarkusPlatformGroupId: String by project
|
||||||
|
val quarkusPlatformArtifactId: String by project
|
||||||
|
val quarkusPlatformVersion: String by project
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA3"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
implementation("org.scala-lang:scala3-library_3") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA3"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
|
implementation("io.quarkus:quarkus-rest")
|
||||||
|
implementation("io.quarkus:quarkus-rest-jackson")
|
||||||
|
implementation("io.quarkus:quarkus-config-yaml")
|
||||||
|
implementation("io.quarkus:quarkus-arc")
|
||||||
|
implementation("io.quarkus:quarkus-hibernate-orm-panache")
|
||||||
|
implementation("io.quarkus:quarkus-jdbc-postgresql")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-jwt")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-jwt-build")
|
||||||
|
implementation("io.quarkus:quarkus-elytron-security-common")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-health")
|
||||||
|
implementation("io.quarkus:quarkus-micrometer")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-openapi")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
|
||||||
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
|
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||||
|
testImplementation("io.quarkus:quarkus-junit")
|
||||||
|
testImplementation("io.rest-assured:rest-assured")
|
||||||
|
testImplementation("io.quarkus:quarkus-jdbc-h2")
|
||||||
|
testImplementation("io.quarkus:quarkus-test-security")
|
||||||
|
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
|
||||||
|
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
|
||||||
|
}
|
||||||
|
configurations.scoverage {
|
||||||
|
resolutionStrategy.eachDependency {
|
||||||
|
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
|
||||||
|
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.compilerArgs.add("-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Jar>().configureEach {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeEngines("scalatest", "junit-jupiter")
|
||||||
|
testLogging {
|
||||||
|
events("passed", "skipped", "failed")
|
||||||
|
showStandardStreams = true
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
}
|
||||||
|
tasks.reportScoverage {
|
||||||
|
dependsOn(tasks.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.jar {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.nowchess.account.config
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.Version
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
|
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class JacksonConfig extends ObjectMapperCustomizer:
|
||||||
|
def customize(mapper: ObjectMapper): Unit =
|
||||||
|
mapper.registerModule(new DefaultScalaModule() {
|
||||||
|
override def version(): Version =
|
||||||
|
// scalafix:off DisableSyntax.null
|
||||||
|
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
|
||||||
|
// scalafix:on DisableSyntax.null
|
||||||
|
})
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.nowchess.account.config
|
||||||
|
|
||||||
|
import de.nowchess.account.domain.{ChallengeColor, ChallengeStatus, DeclineReason}
|
||||||
|
import de.nowchess.account.dto.*
|
||||||
|
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||||
|
|
||||||
|
@RegisterForReflection(
|
||||||
|
targets = Array(
|
||||||
|
classOf[RegisterRequest],
|
||||||
|
classOf[LoginRequest],
|
||||||
|
classOf[TokenResponse],
|
||||||
|
classOf[PlayerInfo],
|
||||||
|
classOf[PublicAccountDto],
|
||||||
|
classOf[TimeControlDto],
|
||||||
|
classOf[ChallengeRequest],
|
||||||
|
classOf[ChallengeDto],
|
||||||
|
classOf[DeclineRequest],
|
||||||
|
classOf[ChallengeListDto],
|
||||||
|
classOf[ErrorDto],
|
||||||
|
classOf[ChallengeStatus],
|
||||||
|
classOf[ChallengeColor],
|
||||||
|
classOf[DeclineReason],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class NativeReflectionConfig
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "accounts")
|
||||||
|
class Account extends PanacheEntityBase:
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
var id: UUID = uninitialized
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
var username: String = uninitialized
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
var email: String = uninitialized
|
||||||
|
|
||||||
|
var passwordHash: String = uninitialized
|
||||||
|
|
||||||
|
var rating: Int = 1500
|
||||||
|
|
||||||
|
var createdAt: Instant = uninitialized
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
import scala.Conversion
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "challenges")
|
||||||
|
class Challenge extends PanacheEntityBase:
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
var id: UUID = uninitialized
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
var challenger: Account = uninitialized
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
var destUser: Account = uninitialized
|
||||||
|
|
||||||
|
@Convert(converter = classOf[ChallengeColorConverter])
|
||||||
|
@Column(columnDefinition = "varchar(255)")
|
||||||
|
var color: ChallengeColor = uninitialized
|
||||||
|
|
||||||
|
@Convert(converter = classOf[ChallengeStatusConverter])
|
||||||
|
@Column(columnDefinition = "varchar(255)")
|
||||||
|
var status: ChallengeStatus = uninitialized
|
||||||
|
|
||||||
|
@Convert(converter = classOf[DeclineReasonConverter])
|
||||||
|
@Column(nullable = true, columnDefinition = "varchar(255)")
|
||||||
|
var declineReason: DeclineReason = uninitialized
|
||||||
|
|
||||||
|
var timeControlType: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
var timeControlLimit: java.lang.Integer = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
var timeControlIncrement: java.lang.Integer = uninitialized
|
||||||
|
|
||||||
|
var createdAt: Instant = uninitialized
|
||||||
|
|
||||||
|
var expiresAt: Instant = uninitialized
|
||||||
|
|
||||||
|
def declineReasonOpt: Option[DeclineReason] = Option(declineReason)
|
||||||
|
def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue())
|
||||||
|
def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
enum ChallengeColor:
|
||||||
|
case White, Black, Random
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
@Converter(autoApply = true)
|
||||||
|
class ChallengeColorConverter extends AttributeConverter[ChallengeColor, String]:
|
||||||
|
override def convertToDatabaseColumn(attribute: ChallengeColor): String =
|
||||||
|
if attribute == null then null else attribute.toString
|
||||||
|
|
||||||
|
override def convertToEntityAttribute(dbData: String): ChallengeColor =
|
||||||
|
if dbData == null then null
|
||||||
|
else
|
||||||
|
ChallengeColor.valueOf(dbData)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
enum ChallengeStatus:
|
||||||
|
case Created, Canceled, Declined, Accepted
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
@Converter(autoApply = true)
|
||||||
|
class ChallengeStatusConverter extends AttributeConverter[ChallengeStatus, String]:
|
||||||
|
override def convertToDatabaseColumn(attribute: ChallengeStatus): String =
|
||||||
|
if attribute == null then null else attribute.toString
|
||||||
|
|
||||||
|
override def convertToEntityAttribute(dbData: String): ChallengeStatus =
|
||||||
|
if dbData == null then null
|
||||||
|
else
|
||||||
|
ChallengeStatus.valueOf(dbData)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
enum DeclineReason:
|
||||||
|
case Generic, Later, TooFast, TooSlow, TimeControl, Rated, Casual, Standard, Variant, NoBot, OnlyBot
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
@Converter(autoApply = true)
|
||||||
|
class DeclineReasonConverter extends AttributeConverter[DeclineReason, String]:
|
||||||
|
override def convertToDatabaseColumn(attribute: DeclineReason): String =
|
||||||
|
if attribute == null then null else attribute.toString
|
||||||
|
|
||||||
|
override def convertToEntityAttribute(dbData: String): DeclineReason =
|
||||||
|
if dbData == null then null
|
||||||
|
else
|
||||||
|
DeclineReason.valueOf(dbData)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.nowchess.account.domain
|
||||||
|
|
||||||
|
sealed trait TimeControl
|
||||||
|
|
||||||
|
object TimeControl:
|
||||||
|
case class Clock(limit: Int, increment: Int) extends TimeControl
|
||||||
|
case object Unlimited extends TimeControl
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.nowchess.account.dto
|
||||||
|
|
||||||
|
case class RegisterRequest(username: String, email: String, password: String)
|
||||||
|
|
||||||
|
case class LoginRequest(username: String, password: String)
|
||||||
|
|
||||||
|
case class TokenResponse(token: String)
|
||||||
|
|
||||||
|
case class PlayerInfo(id: String, name: String, rating: Int)
|
||||||
|
|
||||||
|
case class PublicAccountDto(id: String, username: String, rating: Int, createdAt: String)
|
||||||
|
|
||||||
|
case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[Int])
|
||||||
|
|
||||||
|
case class ChallengeRequest(color: String, timeControl: TimeControlDto)
|
||||||
|
|
||||||
|
case class ChallengeDto(
|
||||||
|
id: String,
|
||||||
|
challenger: PlayerInfo,
|
||||||
|
destUser: PlayerInfo,
|
||||||
|
variant: String,
|
||||||
|
color: String,
|
||||||
|
timeControl: TimeControlDto,
|
||||||
|
status: String,
|
||||||
|
declineReason: Option[String],
|
||||||
|
createdAt: String,
|
||||||
|
expiresAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class DeclineRequest(reason: Option[String])
|
||||||
|
|
||||||
|
case class ChallengeListDto(in: List[ChallengeDto], out: List[ChallengeDto])
|
||||||
|
|
||||||
|
case class ErrorDto(error: String)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.nowchess.account.repository
|
||||||
|
|
||||||
|
import de.nowchess.account.domain.Account
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class AccountRepository:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var em: EntityManager = scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
def findByUsername(username: String): Option[Account] =
|
||||||
|
em.createQuery("FROM Account WHERE username = :username", classOf[Account])
|
||||||
|
.setParameter("username", username)
|
||||||
|
.getResultList
|
||||||
|
.stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(Option(_))
|
||||||
|
.orElse(None)
|
||||||
|
|
||||||
|
def findById(id: UUID): Option[Account] =
|
||||||
|
Option(em.find(classOf[Account], id))
|
||||||
|
|
||||||
|
def persist(account: Account): Account =
|
||||||
|
em.persist(account)
|
||||||
|
account
|
||||||
|
|
||||||
|
def findByEmail(email: String): Option[Account] =
|
||||||
|
em.createQuery("FROM Account WHERE email = :email", classOf[Account])
|
||||||
|
.setParameter("email", email)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.headOption
|
||||||
|
|
||||||
|
def findAll(): List[Account] =
|
||||||
|
em.createQuery("FROM Account", classOf[Account])
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package de.nowchess.account.repository
|
||||||
|
|
||||||
|
import de.nowchess.account.domain.{Challenge, ChallengeStatus}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class ChallengeRepository:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var em: EntityManager = scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
def findActiveByChallengerId(challengerId: UUID): List[Challenge] =
|
||||||
|
em.createQuery(
|
||||||
|
"FROM Challenge WHERE challenger.id = :cid AND status = :status AND expiresAt > :now",
|
||||||
|
classOf[Challenge],
|
||||||
|
).setParameter("cid", challengerId)
|
||||||
|
.setParameter("status", ChallengeStatus.Created)
|
||||||
|
.setParameter("now", Instant.now())
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
|
|
||||||
|
def findActiveByDestUserId(destUserId: UUID): List[Challenge] =
|
||||||
|
em.createQuery(
|
||||||
|
"FROM Challenge WHERE destUser.id = :uid AND status = :status AND expiresAt > :now",
|
||||||
|
classOf[Challenge],
|
||||||
|
).setParameter("uid", destUserId)
|
||||||
|
.setParameter("status", ChallengeStatus.Created)
|
||||||
|
.setParameter("now", Instant.now())
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
|
|
||||||
|
def findDuplicateChallenge(challengerId: UUID, destUserId: UUID): Option[Challenge] =
|
||||||
|
em.createQuery(
|
||||||
|
"FROM Challenge WHERE challenger.id = :cid AND destUser.id = :uid AND status = :status AND expiresAt > :now",
|
||||||
|
classOf[Challenge],
|
||||||
|
).setParameter("cid", challengerId)
|
||||||
|
.setParameter("uid", destUserId)
|
||||||
|
.setParameter("status", ChallengeStatus.Created)
|
||||||
|
.setParameter("now", Instant.now())
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.headOption
|
||||||
|
|
||||||
|
def findById(id: UUID): Option[Challenge] =
|
||||||
|
Option(em.find(classOf[Challenge], id))
|
||||||
|
|
||||||
|
def persist(challenge: Challenge): Challenge =
|
||||||
|
em.persist(challenge)
|
||||||
|
challenge
|
||||||
|
|
||||||
|
def merge(challenge: Challenge): Challenge =
|
||||||
|
em.merge(challenge)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
|
import de.nowchess.account.domain.Account
|
||||||
|
import de.nowchess.account.dto.*
|
||||||
|
import de.nowchess.account.service.AccountService
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Path("/api/account")
|
||||||
|
@ApplicationScoped
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
class AccountResource:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var accountService: AccountService = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var jwt: JsonWebToken = uninitialized
|
||||||
|
|
||||||
|
@POST
|
||||||
|
def register(req: RegisterRequest): Response =
|
||||||
|
accountService.register(req) match
|
||||||
|
case Right(account) =>
|
||||||
|
Response.ok(toPublicDto(account)).build()
|
||||||
|
case Left(error) =>
|
||||||
|
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error)).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/login")
|
||||||
|
def login(req: LoginRequest): Response =
|
||||||
|
accountService.login(req) match
|
||||||
|
case Right(token) =>
|
||||||
|
Response.ok(TokenResponse(token)).build()
|
||||||
|
case Left(error) =>
|
||||||
|
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/me")
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
def me(): Response =
|
||||||
|
val id = UUID.fromString(jwt.getSubject)
|
||||||
|
accountService.findById(id) match
|
||||||
|
case Some(account) => Response.ok(toPublicDto(account)).build()
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{username}")
|
||||||
|
def publicProfile(@PathParam("username") username: String): Response =
|
||||||
|
accountService.findByUsername(username) match
|
||||||
|
case Some(account) => Response.ok(toPublicDto(account)).build()
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).build()
|
||||||
|
|
||||||
|
private def toPublicDto(account: Account): PublicAccountDto =
|
||||||
|
PublicAccountDto(
|
||||||
|
id = account.id.toString,
|
||||||
|
username = account.username,
|
||||||
|
rating = account.rating,
|
||||||
|
createdAt = account.createdAt.toString,
|
||||||
|
)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
|
import de.nowchess.account.dto.*
|
||||||
|
import de.nowchess.account.service.ChallengeService
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Path("/api/challenge")
|
||||||
|
@ApplicationScoped
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
class ChallengeResource:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var challengeService: ChallengeService = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var jwt: JsonWebToken = uninitialized
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{username}")
|
||||||
|
def create(@PathParam("username") username: String, req: ChallengeRequest): Response =
|
||||||
|
val userId = UUID.fromString(jwt.getSubject)
|
||||||
|
challengeService.create(userId, username, req) match
|
||||||
|
case Right(challenge) =>
|
||||||
|
Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build()
|
||||||
|
case Left(error) =>
|
||||||
|
val status = if error.contains("not found") then Response.Status.NOT_FOUND
|
||||||
|
else if error.contains("yourself") then Response.Status.BAD_REQUEST
|
||||||
|
else Response.Status.CONFLICT
|
||||||
|
Response.status(status).entity(ErrorDto(error)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
def list(): Response =
|
||||||
|
val userId = UUID.fromString(jwt.getSubject)
|
||||||
|
Response.ok(challengeService.listForUser(userId)).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/accept")
|
||||||
|
def accept(@PathParam("id") id: UUID): Response =
|
||||||
|
val userId = UUID.fromString(jwt.getSubject)
|
||||||
|
challengeService.accept(id, userId) match
|
||||||
|
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/decline")
|
||||||
|
def decline(@PathParam("id") id: UUID, req: DeclineRequest): Response =
|
||||||
|
val userId = UUID.fromString(jwt.getSubject)
|
||||||
|
challengeService.decline(id, userId, req) match
|
||||||
|
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/cancel")
|
||||||
|
def cancel(@PathParam("id") id: UUID): Response =
|
||||||
|
val userId = UUID.fromString(jwt.getSubject)
|
||||||
|
challengeService.cancel(id, userId) match
|
||||||
|
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
private def errorResponse(error: String): Response =
|
||||||
|
val status =
|
||||||
|
if error.contains("not found") then Response.Status.NOT_FOUND
|
||||||
|
else if error.contains("authorized") then Response.Status.FORBIDDEN
|
||||||
|
else Response.Status.BAD_REQUEST
|
||||||
|
Response.status(status).entity(ErrorDto(error)).build()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.nowchess.account.service
|
||||||
|
|
||||||
|
import de.nowchess.account.domain.Account
|
||||||
|
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
|
||||||
|
import de.nowchess.account.repository.AccountRepository
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil
|
||||||
|
import io.smallrye.jwt.build.Jwt
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.transaction.Transactional
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class AccountService:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var accountRepository: AccountRepository = uninitialized
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def register(req: RegisterRequest): Either[String, Account] =
|
||||||
|
if accountRepository.findByUsername(req.username).isDefined then
|
||||||
|
Left(s"Username '${req.username}' is already taken")
|
||||||
|
else if accountRepository.findByEmail(req.email).isDefined then
|
||||||
|
Left(s"Email '${req.email}' is already registered")
|
||||||
|
else
|
||||||
|
val account = new Account()
|
||||||
|
account.username = req.username
|
||||||
|
account.email = req.email
|
||||||
|
account.passwordHash = BcryptUtil.bcryptHash(req.password)
|
||||||
|
account.createdAt = Instant.now()
|
||||||
|
accountRepository.persist(account)
|
||||||
|
Right(account)
|
||||||
|
|
||||||
|
def login(req: LoginRequest): Either[String, String] =
|
||||||
|
accountRepository
|
||||||
|
.findByUsername(req.username)
|
||||||
|
.filter(a => BcryptUtil.matches(req.password, a.passwordHash))
|
||||||
|
.map { account =>
|
||||||
|
Jwt
|
||||||
|
.issuer("nowchess")
|
||||||
|
.subject(account.id.toString)
|
||||||
|
.claim("username", account.username)
|
||||||
|
.sign()
|
||||||
|
}
|
||||||
|
.toRight("Invalid credentials")
|
||||||
|
|
||||||
|
def findByUsername(username: String): Option[Account] =
|
||||||
|
accountRepository.findByUsername(username)
|
||||||
|
|
||||||
|
def findById(id: UUID): Option[Account] =
|
||||||
|
accountRepository.findById(id)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.nowchess.account.service
|
||||||
|
|
||||||
|
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
|
||||||
|
import de.nowchess.account.dto.{ChallengeDto, ChallengeListDto, ChallengeRequest, DeclineRequest, PlayerInfo, TimeControlDto}
|
||||||
|
import de.nowchess.account.repository.{AccountRepository, ChallengeRepository}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.transaction.Transactional
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class ChallengeService:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var accountRepository: AccountRepository = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var challengeRepository: ChallengeRepository = uninitialized
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[String, Challenge] =
|
||||||
|
for
|
||||||
|
destUser <- accountRepository.findByUsername(destUsername).toRight(s"User '$destUsername' not found")
|
||||||
|
challenger <- accountRepository.findById(challengerId).toRight("Challenger not found")
|
||||||
|
_ <- Either.cond(challenger.id != destUser.id, (), "Cannot challenge yourself")
|
||||||
|
_ <- Either.cond(
|
||||||
|
challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
|
||||||
|
(),
|
||||||
|
"Active challenge to this user already exists",
|
||||||
|
)
|
||||||
|
color <- parseColor(req.color)
|
||||||
|
yield
|
||||||
|
val challenge = new Challenge()
|
||||||
|
challenge.challenger = challenger
|
||||||
|
challenge.destUser = destUser
|
||||||
|
challenge.color = color
|
||||||
|
challenge.status = ChallengeStatus.Created
|
||||||
|
challenge.timeControlType = req.timeControl.`type`
|
||||||
|
challenge.timeControlLimit =
|
||||||
|
req.timeControl.limit.map(java.lang.Integer.valueOf).orNull
|
||||||
|
challenge.timeControlIncrement =
|
||||||
|
req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
|
||||||
|
challenge.createdAt = Instant.now()
|
||||||
|
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
|
||||||
|
challengeRepository.persist(challenge)
|
||||||
|
challenge
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def accept(challengeId: UUID, userId: UUID): Either[String, Challenge] =
|
||||||
|
for
|
||||||
|
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
|
||||||
|
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
|
||||||
|
_ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to accept this challenge")
|
||||||
|
yield
|
||||||
|
challenge.status = ChallengeStatus.Accepted
|
||||||
|
challengeRepository.merge(challenge)
|
||||||
|
challenge
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[String, Challenge] =
|
||||||
|
for
|
||||||
|
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
|
||||||
|
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
|
||||||
|
_ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to decline this challenge")
|
||||||
|
reason <- parseDeclineReason(req.reason)
|
||||||
|
yield
|
||||||
|
challenge.status = ChallengeStatus.Declined
|
||||||
|
challenge.declineReason = reason.orNull
|
||||||
|
challengeRepository.merge(challenge)
|
||||||
|
challenge
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def cancel(challengeId: UUID, userId: UUID): Either[String, Challenge] =
|
||||||
|
for
|
||||||
|
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
|
||||||
|
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
|
||||||
|
_ <- Either.cond(challenge.challenger.id == userId, (), "Not authorized to cancel this challenge")
|
||||||
|
yield
|
||||||
|
challenge.status = ChallengeStatus.Canceled
|
||||||
|
challengeRepository.merge(challenge)
|
||||||
|
challenge
|
||||||
|
|
||||||
|
def listForUser(userId: UUID): ChallengeListDto =
|
||||||
|
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
|
||||||
|
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
||||||
|
ChallengeListDto(in = incoming, out = outgoing)
|
||||||
|
|
||||||
|
private def parseColor(raw: String): Either[String, ChallengeColor] =
|
||||||
|
raw.toLowerCase match
|
||||||
|
case "white" => Right(ChallengeColor.White)
|
||||||
|
case "black" => Right(ChallengeColor.Black)
|
||||||
|
case "random" => Right(ChallengeColor.Random)
|
||||||
|
case _ => Left(s"Unknown color: $raw")
|
||||||
|
|
||||||
|
private def parseDeclineReason(raw: Option[String]): Either[String, Option[DeclineReason]] =
|
||||||
|
raw match
|
||||||
|
case None => Right(None)
|
||||||
|
case Some(r) =>
|
||||||
|
DeclineReason.values.find(_.toString.equalsIgnoreCase(r)) match
|
||||||
|
case Some(reason) => Right(Some(reason))
|
||||||
|
case None => Left(s"Unknown decline reason: $r")
|
||||||
|
|
||||||
|
def toDto(c: Challenge): ChallengeDto =
|
||||||
|
ChallengeDto(
|
||||||
|
id = c.id.toString,
|
||||||
|
challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating),
|
||||||
|
destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating),
|
||||||
|
variant = "standard",
|
||||||
|
color = c.color.toString.toLowerCase,
|
||||||
|
timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
|
||||||
|
status = c.status.toString.toLowerCase,
|
||||||
|
declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
|
||||||
|
createdAt = c.createdAt.toString,
|
||||||
|
expiresAt = c.expiresAt.toString,
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6
|
||||||
|
wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X
|
||||||
|
PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB
|
||||||
|
oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0
|
||||||
|
jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH
|
||||||
|
GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK
|
||||||
|
FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD
|
||||||
|
ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D
|
||||||
|
wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa
|
||||||
|
UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz
|
||||||
|
3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC
|
||||||
|
B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f
|
||||||
|
Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P
|
||||||
|
GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j
|
||||||
|
cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy
|
||||||
|
aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw
|
||||||
|
ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6
|
||||||
|
DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd
|
||||||
|
0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1
|
||||||
|
TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4
|
||||||
|
8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r
|
||||||
|
G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13
|
||||||
|
clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E
|
||||||
|
wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r
|
||||||
|
kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew
|
||||||
|
5i4ReFwm3/IWDn9kLmHT6Fg=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y
|
||||||
|
VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V
|
||||||
|
MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW
|
||||||
|
WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB
|
||||||
|
QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X
|
||||||
|
FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ
|
||||||
|
BwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import io.restassured.http.ContentType
|
||||||
|
import org.hamcrest.Matchers.*
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class AccountResourceTest:
|
||||||
|
|
||||||
|
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||||
|
|
||||||
|
private def registerBody(username: String, email: String = "", password: String = "secret") =
|
||||||
|
val resolvedEmail = if email.isEmpty then s"$username@example.com" else email
|
||||||
|
s"""{"username":"$username","email":"$resolvedEmail","password":"$password"}"""
|
||||||
|
|
||||||
|
private def loginBody(username: String, password: String = "secret") =
|
||||||
|
s"""{"username":"$username","password":"$password"}"""
|
||||||
|
|
||||||
|
private def registerAndLogin(username: String): String =
|
||||||
|
givenRequest()
|
||||||
|
.body(registerBody(username))
|
||||||
|
.when()
|
||||||
|
.post("/api/account")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
givenRequest()
|
||||||
|
.body(loginBody(username))
|
||||||
|
.when()
|
||||||
|
.post("/api/account/login")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path[String]("token")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def registerReturns200(): Unit =
|
||||||
|
givenRequest()
|
||||||
|
.body(registerBody("alice"))
|
||||||
|
.when()
|
||||||
|
.post("/api/account")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is("alice"))
|
||||||
|
.body("rating", is(1500))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def registerConflictOnDuplicateUsername(): Unit =
|
||||||
|
givenRequest().body(registerBody("bob")).when().post("/api/account")
|
||||||
|
givenRequest()
|
||||||
|
.body(registerBody("bob"))
|
||||||
|
.when()
|
||||||
|
.post("/api/account")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(409)
|
||||||
|
.body("error", containsString("bob"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def loginReturns200WithToken(): Unit =
|
||||||
|
givenRequest().body(registerBody("charlie")).when().post("/api/account")
|
||||||
|
givenRequest()
|
||||||
|
.body(loginBody("charlie"))
|
||||||
|
.when()
|
||||||
|
.post("/api/account/login")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("token", notNullValue())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def loginUnauthorizedOnWrongPassword(): Unit =
|
||||||
|
givenRequest().body(registerBody("dave")).when().post("/api/account")
|
||||||
|
givenRequest()
|
||||||
|
.body(loginBody("dave", "wrongpassword"))
|
||||||
|
.when()
|
||||||
|
.post("/api/account/login")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(401)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getMeReturns200(): Unit =
|
||||||
|
val token = registerAndLogin("eve")
|
||||||
|
givenRequest()
|
||||||
|
.header("Authorization", s"Bearer $token")
|
||||||
|
.when()
|
||||||
|
.get("/api/account/me")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is("eve"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getPublicProfileReturns200(): Unit =
|
||||||
|
givenRequest().body(registerBody("frank")).when().post("/api/account")
|
||||||
|
givenRequest()
|
||||||
|
.when()
|
||||||
|
.get("/api/account/frank")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is("frank"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getPublicProfileNotFound(): Unit =
|
||||||
|
givenRequest()
|
||||||
|
.when()
|
||||||
|
.get("/api/account/doesnotexist")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import io.restassured.http.ContentType
|
||||||
|
import org.hamcrest.Matchers.*
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class ChallengeResourceTest:
|
||||||
|
|
||||||
|
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||||
|
|
||||||
|
private def registerBody(username: String, suffix: String = "") =
|
||||||
|
val email = s"$username$suffix@test.com"
|
||||||
|
s"""{"username":"$username$suffix","email":"$email","password":"secret"}"""
|
||||||
|
|
||||||
|
private def loginBody(username: String, suffix: String = "") =
|
||||||
|
s"""{"username":"$username$suffix","password":"secret"}"""
|
||||||
|
|
||||||
|
private def registerAndLogin(username: String, suffix: String = ""): String =
|
||||||
|
givenRequest().body(registerBody(username, suffix)).when().post("/api/account")
|
||||||
|
givenRequest()
|
||||||
|
.body(loginBody(username, suffix))
|
||||||
|
.when()
|
||||||
|
.post("/api/account/login")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path[String]("token")
|
||||||
|
|
||||||
|
private val clockBody =
|
||||||
|
"""{"color":"random","timeControl":{"type":"clock","limit":300,"increment":5}}"""
|
||||||
|
|
||||||
|
private def authed(token: String) =
|
||||||
|
givenRequest().header("Authorization", s"Bearer $token")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createChallengeReturns201(): Unit =
|
||||||
|
val t1 = registerAndLogin("user1c")
|
||||||
|
registerAndLogin("user2c")
|
||||||
|
authed(t1)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(clockBody)
|
||||||
|
.when()
|
||||||
|
.post("/api/challenge/user2c")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("status", is("created"))
|
||||||
|
.body("color", is("random"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createChallengeConflictOnDuplicate(): Unit =
|
||||||
|
val t1 = registerAndLogin("user1dup")
|
||||||
|
registerAndLogin("user2dup")
|
||||||
|
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/user2dup")
|
||||||
|
authed(t1)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(clockBody)
|
||||||
|
.when()
|
||||||
|
.post("/api/challenge/user2dup")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createChallengeSelfForbidden(): Unit =
|
||||||
|
val token = registerAndLogin("selfuser")
|
||||||
|
authed(token)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(clockBody)
|
||||||
|
.when()
|
||||||
|
.post("/api/challenge/selfuser")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def acceptChallengeReturns200(): Unit =
|
||||||
|
val t1 = registerAndLogin("accUser1")
|
||||||
|
val t2 = registerAndLogin("accUser2")
|
||||||
|
val challengeId = authed(t1)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(clockBody)
|
||||||
|
.when()
|
||||||
|
.post("/api/challenge/accUser2")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("id")
|
||||||
|
authed(t2)
|
||||||
|
.when()
|
||||||
|
.post(s"/api/challenge/$challengeId/accept")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("status", is("accepted"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def declineChallengeReturns200(): Unit =
|
||||||
|
val t1 = registerAndLogin("decUser1")
|
||||||
|
val t2 = registerAndLogin("decUser2")
|
||||||
|
val challengeId = authed(t1)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(clockBody)
|
||||||
|
.when()
|
||||||
|
.post("/api/challenge/decUser2")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("id")
|
||||||
|
authed(t2)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("""{"reason":"later"}""")
|
||||||
|
.when()
|
||||||
|
.post(s"/api/challenge/$challengeId/decline")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("status", is("declined"))
|
||||||
|
.body("declineReason", is("later"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def cancelChallengeReturns200(): Unit =
|
||||||
|
val t1 = registerAndLogin("canUser1")
|
||||||
|
registerAndLogin("canUser2")
|
||||||
|
val challengeId = authed(t1)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(clockBody)
|
||||||
|
.when()
|
||||||
|
.post("/api/challenge/canUser2")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("id")
|
||||||
|
authed(t1)
|
||||||
|
.when()
|
||||||
|
.post(s"/api/challenge/$challengeId/cancel")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("status", is("canceled"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def listChallengesReturnsInAndOut(): Unit =
|
||||||
|
val t1 = registerAndLogin("listUser1")
|
||||||
|
registerAndLogin("listUser2")
|
||||||
|
registerAndLogin("listUser3")
|
||||||
|
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/listUser2").`then`().statusCode(201)
|
||||||
|
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/listUser3").`then`().statusCode(201)
|
||||||
|
authed(t1)
|
||||||
|
.when()
|
||||||
|
.get("/api/challenge")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("out.size()", is(2))
|
||||||
|
.body("in.size()", is(0))
|
||||||
Reference in New Issue
Block a user