From 0c5ec03239b28ed2075622825695c7475d248e6b Mon Sep 17 00:00:00 2001 From: irinaschubert Date: Mon, 13 Jun 2022 09:51:26 +0200 Subject: [PATCH] refactor(user): add user project (DEV-586) (#2063) * add user project * add simple UserHandler methods * add unit test for user repo * add new structure for tests * refactoring some code * clean up code * run UserRepoSpec for all implementations * add more methods and tests to user handler * restructure code * fix test * update structure of user slice * test: fix race condition by providing different environments to each test * wip * fix failing tests * update dependencies for shared project * add method to check if username or email is taken * improve UserUserHandlerSpec * organize imports Co-authored-by: Balduin Landolt <33053745+BalduinLandolt@users.noreply.github.com> Co-authored-by: Marcin Procyk --- build.sbt | 79 ++++++++++ .../main/scala/dsp/user/api/UserRepo.scala | 70 +++++++++ .../scala/dsp/user/domain/UserDomain.scala | 142 ++++++++++++++++++ .../test/scala/dsp/user/api/UserApiSpec.scala | 6 + .../dsp/user/domain/UserDomainSpec.scala | 6 + .../scala/dsp/user/handler/UserHandler.scala | 141 +++++++++++++++++ .../dsp/user/handler/UserHandlerSpec.scala | 96 ++++++++++++ .../external/UserListenerExternal.scala | 6 + .../internal/UserListenerInternal.scala | 6 + .../main/scala/dsp/user/route/UserRoute.scala | 6 + .../external/UserListenerExternalSpec.scala | 6 + .../internal/UserListenerInternalSpec.scala | 6 + .../scala/dsp/user/route/UserRouteSpec.scala | 6 + .../dsp/user/repo/impl/UserRepoLive.scala | 99 ++++++++++++ .../dsp/user/repo/impl/UserRepoImplSpec.scala | 95 ++++++++++++ .../dsp/user/repo/impl/UserRepoMock.scala | 107 +++++++++++++ project/Dependencies.scala | 49 +++++- project/plugins.sbt | 2 +- .../knora/webapi/app/ApplicationActor.scala | 7 +- 19 files changed, 924 insertions(+), 11 deletions(-) create mode 100644 dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala create mode 100644 dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala create mode 100644 dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala create mode 100644 dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala create mode 100644 dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala create mode 100644 dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala create mode 100644 dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala create mode 100644 dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala create mode 100644 dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala create mode 100644 dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala create mode 100644 dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala create mode 100644 dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala create mode 100644 dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala create mode 100644 dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala create mode 100644 dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala diff --git a/build.sbt b/build.sbt index 29b6c5f596..4b5100aa27 100644 --- a/build.sbt +++ b/build.sbt @@ -240,6 +240,18 @@ lazy val apiMain = project ) .dependsOn(schemaCore, schemaRepo, schemaApi) +// Value Objects project + +lazy val valueObjects = project + .in(file("dsp-value-objects")) + .settings( + name := "valueObjects", + libraryDependencies ++= Dependencies.valueObjectsLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + +// Schema projects + lazy val schemaApi = project .in(file("dsp-schema/api")) .settings( @@ -284,6 +296,73 @@ lazy val schemaRepoSearchService = project ) .dependsOn(schemaRepo) +// User projects + +lazy val userInterface = project + .in(file("dsp-user/interface")) + .settings( + scalacOptions ++= Seq( + "-feature", + "-unchecked", + "-deprecation", + "-Yresolve-term-conflict:package", + "-Ymacro-annotations" + ), + name := "userInterface", + libraryDependencies ++= Dependencies.userInterfaceLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared, userHandler) + +lazy val userHandler = project + .in(file("dsp-user/handler")) + .settings( + scalacOptions ++= Seq( + "-feature", + "-unchecked", + "-deprecation", + "-Yresolve-term-conflict:package", + "-Ymacro-annotations" + ), + name := "userHandler", + libraryDependencies ++= Dependencies.userHandlerLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared, userCore, userRepo % "test->test") //userHandler tests need mock implementation of UserRepo + +lazy val userCore = project + .in(file("dsp-user/core")) + .settings( + scalacOptions ++= Seq( + "-feature", + "-unchecked", + "-deprecation", + "-Yresolve-term-conflict:package", + "-Ymacro-annotations" + ), + name := "userCore", + libraryDependencies ++= Dependencies.userCoreLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared) + +lazy val userRepo = project + .in(file("dsp-user/repo")) + .settings( + scalacOptions ++= Seq( + "-feature", + "-unchecked", + "-deprecation", + "-Yresolve-term-conflict:package", + "-Ymacro-annotations" + ), + name := "userRepo", + libraryDependencies ++= Dependencies.userRepoLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared, userCore) +//.dependsOn(userHandler % "compile->compile;test->test", userDomain) + lazy val shared = project .in(file("dsp-shared")) .settings( diff --git a/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala b/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala new file mode 100644 index 0000000000..1f63806081 --- /dev/null +++ b/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala @@ -0,0 +1,70 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.api + +import dsp.errors._ +import dsp.user.domain._ +import zio._ +import zio.macros.accessible + +import java.util.UUID + +/** + * The trait (interface) for the user repository. The user repository is responsible for storing and retrieving users. + * Needs to be used by the user repository implementations. + */ +@accessible // with this annotation we don't have to write the companion object ourselves +trait UserRepo { + + /** + * Writes a user to the repository (used for both create and update). + * If this fails (e.g. the triplestore is not available), it's a non-recovable error. That's why we need UIO. + * When used, we should do it like: ...store(...).orDie + * + * @param user the user to write + * @return Unit + */ + def storeUser(user: User): UIO[UserId] + + /** + * Gets all users from the repository. + * + * @return a list of [[User]] + */ + def getUsers(): UIO[List[User]] + + /** + * Retrieves the user from the repository by ID. + * + * @param id the user's ID + * @return an optional [[User]] + */ + def getUserById(id: UserId): IO[Option[Nothing], User] + + /** + * Retrieves the user from the repository by username or email. + * + * @param usernameOrEmail username or email of the user. + * @return an optional [[User]]. + */ + def getUserByUsernameOrEmail(usernameOrEmail: String): IO[Option[Nothing], User] + + /** + * Checks if a username or email exists in the repo. + * + * @param usernameOrEmail username or email of the user. + * @return Unit in case of success + */ + def checkUsernameOrEmailExists(usernameOrEmail: String): IO[Option[Nothing], Unit] + + /** + * Deletes a [[User]] from the repository by its [[UserId]]. + * + * @param id the user ID + * @return Unit or None if not found + */ + def deleteUser(id: UserId): IO[Option[Nothing], UserId] +} diff --git a/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala b/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala new file mode 100644 index 0000000000..1d66dfea1e --- /dev/null +++ b/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala @@ -0,0 +1,142 @@ +/* + * Copyright © 2021 - 2022 Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.domain + +import dsp.valueobjects.User._ +import zio.prelude.Validation + +import java.util.UUID + +// move this to shared value objects project once we have it +sealed trait Iri +object Iri { + + /** + * UserIri value object. + */ + sealed abstract case class UserIri private (value: String) extends Iri + object UserIri { self => + + def make(value: String): UserIri = new UserIri(value) {} + } + // ... + +} + +/** + * Stores the user ID, i.e. UUID and IRI of the user + * + * @param uuid the UUID of the user + * @param iri the IRI of the user + */ +abstract case class UserId private ( + uuid: UUID, + iri: Iri.UserIri +) + +/** + * Companion object for UserId. Contains factory methods for creating UserId instances. + */ +object UserId { + + /** + * Generates a UserId instance from a given string (either UUID or IRI). + * + * @param value the string to parse (either UUID or IRI) + * @return a new UserId instance + */ + // TODO not sure if we need this + // def fromString(value: String): UserId = { + // val uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$".r + // val iriPattern = "^http*".r + + // value match { + // case uuidPattern(value) => new UserId(UUID.fromString(value), Iri.UserIri.make(value)) {} + // case iriPattern(value) => + // new UserId(UUID.fromString(value.substring(value.lastIndexOf("/") + 1)), Iri.UserIri.make(value)) {} + // //case _ => ??? + // } + // } + + /** + * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * + * @return a new UserId instance + */ + def fromIri(iri: Iri.UserIri): UserId = { + val uuid: UUID = UUID.fromString(iri.value.split("/").last) + new UserId(uuid, iri) {} + } + + /** + * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * + * @return a new UserId instance + */ + def fromUuid(uuid: UUID): UserId = { + val iri: Iri.UserIri = Iri.UserIri.make("http://rdfh.ch/users/" + uuid.toString) + new UserId(uuid, iri) {} + } + + /** + * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * + * @return a new UserId instance + */ + // TODO should this return a Validation[Throwable, UserId] + def make(): UserId = { + val uuid: UUID = UUID.randomUUID() + val iri: Iri.UserIri = Iri.UserIri.make("http://rdfh.ch/users/" + uuid.toString) + new UserId(uuid, iri) {} + } +} + +/** + * Represents the user domain object. + * + * @param id the ID of the user + * @param givenName the given name of the user + * @param familyName the family name of the user + * @param username the username of the user + * @param email the email of the user + * @param password the password of the user + * @param language the user's preferred language + * @param role the user's role + */ +sealed abstract case class User private ( + id: UserId, + givenName: GivenName, + familyName: FamilyName, + username: Username, + email: Email, + password: Option[Password], + language: LanguageCode + //role: Role +) extends Ordered[User] { self => + + /** + * Allows to sort collections of [[User]]s. Sorting is done by the IRI. + */ + def compare(that: User): Int = self.id.iri.toString().compareTo(that.id.iri.toString()) + + def updateUsername(value: Username): User = + new User(self.id, self.givenName, self.familyName, value, self.email, self.password, self.language) {} +} +object User { + def make( + givenName: GivenName, + familyName: FamilyName, + username: Username, + email: Email, + password: Password, + language: LanguageCode + //role: Role + ): User = { + val id = UserId.make() + new User(id, givenName, familyName, username, email, Some(password), language) {} + } + +} diff --git a/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala b/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala new file mode 100644 index 0000000000..f01f75cf8f --- /dev/null +++ b/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.api diff --git a/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala b/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala new file mode 100644 index 0000000000..6c1f2391b3 --- /dev/null +++ b/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.domain diff --git a/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala b/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala new file mode 100644 index 0000000000..650193d2d2 --- /dev/null +++ b/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala @@ -0,0 +1,141 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.handler + +import dsp.errors.BadRequestException +import dsp.errors.DuplicateValueException +import dsp.errors.NotFoundException +import dsp.errors.RequestRejectedException +import dsp.user.api.UserRepo +import dsp.user.domain.User +import dsp.user.domain.UserId +import dsp.valueobjects.User._ +import zio._ + +import java.util.UUID + +/** + * The user handler. + * + * @param repo the user repository + */ +final case class UserHandler(repo: UserRepo) { + // implement all possible requests from V2, but divide things up into smaller functions to keep it cleaner than before + + /** + * Retrieve all users (sorted by IRI). + */ + def getUsers(): UIO[List[User]] = + repo.getUsers().map(_.sorted) + + // getSingleUserADM should be inspected in the route. According to the user identifier, the + // right method from the userHandler should be called. + + /** + * Retrieve the user by ID. + * + * @param id the user's ID + */ + def getUserById(id: UserId): IO[NotFoundException, User] = + for { + user <- repo.getUserById(id).mapError(_ => NotFoundException("User not found")) + } yield user + + /** + * Retrieve the user by username. + * + * @param username the user's username + */ + def getUserByUsername(username: Username): IO[NotFoundException, User] = + repo + .getUserByUsernameOrEmail(username.value) + .mapError(_ => NotFoundException(s"User with Username ${username.value} not found")) + + /** + * Retrieve the user by email. + * + * @param email the user's email + */ + def getUserByEmail(email: Email): IO[NotFoundException, User] = + repo + .getUserByUsernameOrEmail(email.value) + .mapError(_ => NotFoundException(s"User with Email ${email.value} not found")) + + /** + * Check if username is already taken + * + * @param username the user's username + */ + private def checkUsernameTaken(username: Username): IO[DuplicateValueException, Unit] = + for { + _ <- repo + .checkUsernameOrEmailExists(username.value) + .mapError(_ => DuplicateValueException(s"Username ${username.value} already exists")) + } yield () + + /** + * Check if email is already taken + * + * @param email the user's email + */ + private def checkEmailTaken(email: Email): IO[DuplicateValueException, Unit] = + for { + _ <- repo + .checkUsernameOrEmailExists(email.value) + .mapError(_ => DuplicateValueException(s"Email ${email.value} already exists")) + } yield () + + def createUser( + username: Username, + email: Email, + givenName: GivenName, + familyName: FamilyName, + password: Password, + language: LanguageCode + //role: Role + ): IO[DuplicateValueException, UserId] = + for { + _ <- checkUsernameTaken(username) // also lock/reserve username + _ <- checkEmailTaken(email) // also lock/reserve email + user <- ZIO.succeed(User.make(givenName, familyName, username, email, password, language)) + userId <- repo.storeUser(user) // we assume that this can't fail + } yield userId + + def updateUsername(id: UserId, value: Username): IO[RequestRejectedException, UserId] = + for { + _ <- checkUsernameTaken(value) + // lock/reserve username + // check if user exists and get him, lock user + user <- getUserById(id) + userUpdated <- ZIO.succeed(user.updateUsername(value)) + _ <- repo.storeUser(userUpdated) + } yield id + + def updatePassword(id: UserId, newPassword: Password, currentPassword: Password, requestingUser: User) = ??? + // either the user himself or a sysadmin can change a user's password + // in both cases we need the current password of either the user itself or the sysadmin + + def deleteUser(id: UserId): IO[NotFoundException, UserId] = + for { + _ <- repo + .deleteUser(id) + .mapError(_ => NotFoundException(s"User with ID ${id} not found")) + } yield id + +} + +/** + * Companion object providing the layer with an initialized implementation + */ +object UserHandler { + val layer: ZLayer[UserRepo, Nothing, UserHandler] = { + ZLayer { + for { + repo <- ZIO.service[UserRepo] + } yield UserHandler(repo) + }.tap(_ => ZIO.debug(">>> User handler initialized <<<")) + } +} diff --git a/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala b/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala new file mode 100644 index 0000000000..82b7082c5b --- /dev/null +++ b/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala @@ -0,0 +1,96 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.handler + +import dsp.user.domain.User +import dsp.user.domain._ +import dsp.user.repo.impl.UserRepoMock +import dsp.valueobjects.User._ +import zio.ZLayer +import zio._ +import zio.test._ + +/** + * This spec is used to test [[dsp.user.handler.UserHandler]]. + */ +object UserHandlerSpec extends ZIOSpecDefault { + + def spec = (userTests) + + private val givenName = GivenName.make("GivenName1").fold(e => throw e.head, v => v) + private val familyName = FamilyName.make("familyName1").fold(e => throw e.head, v => v) + private val username = Username.make("username1").fold(e => throw e.head, v => v) + private val email = Email.make("email1@email.com").fold(e => throw e.head, v => v) + private val password = Password.make("password1").fold(e => throw e.head, v => v) + private val language = LanguageCode.make("en").fold(e => throw e.head, v => v) + + val userTests = suite("UserHandler")( + test("store a user and retrieve by ID") { + for { + userHandler <- ZIO.service[UserHandler] + + userId <- userHandler.createUser( + username = username, + email = email, + givenName = givenName, + familyName = familyName, + password = password, + language = language + ) + + retrievedUser <- userHandler.getUserById(userId) + _ <- ZIO.debug(retrievedUser) + } yield assertTrue(retrievedUser.username == username) && + assertTrue(retrievedUser.email == email) && + assertTrue(retrievedUser.givenName == givenName) && + assertTrue(retrievedUser.familyName == familyName) && + assertTrue(retrievedUser.password == Some(password)) && + assertTrue(retrievedUser.language == language) + }, + test("store a user and retrieve by username") { + for { + userHandler <- ZIO.service[UserHandler] + + userId <- userHandler.createUser( + username = username, + email = email, + givenName = givenName, + familyName = familyName, + password = password, + language = language + ) + + retrievedUser <- userHandler.getUserByUsername(username) + } yield assertTrue(retrievedUser.username == username) && + assertTrue(retrievedUser.email == email) && + assertTrue(retrievedUser.givenName == givenName) && + assertTrue(retrievedUser.familyName == familyName) && + assertTrue(retrievedUser.password == Some(password)) && + assertTrue(retrievedUser.language == language) + }, + test("store a user and retrieve by email") { + for { + userHandler <- ZIO.service[UserHandler] + + userId <- userHandler.createUser( + username = username, + email = email, + givenName = givenName, + familyName = familyName, + password = password, + language = language + ) + + retrievedUser <- userHandler.getUserByEmail(email) + } yield assertTrue(retrievedUser.username == username) && + assertTrue(retrievedUser.email == email) && + assertTrue(retrievedUser.givenName == givenName) && + assertTrue(retrievedUser.familyName == familyName) && + assertTrue(retrievedUser.password == Some(password)) && + assertTrue(retrievedUser.language == language) + } + ).provide(UserRepoMock.layer, UserHandler.layer) +} diff --git a/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala b/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala new file mode 100644 index 0000000000..55d99ee7c8 --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.listener.external diff --git a/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala b/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala new file mode 100644 index 0000000000..9aa3296287 --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.listener.internal diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala new file mode 100644 index 0000000000..631ae35b69 --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.route diff --git a/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala new file mode 100644 index 0000000000..55d99ee7c8 --- /dev/null +++ b/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.listener.external diff --git a/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala new file mode 100644 index 0000000000..9aa3296287 --- /dev/null +++ b/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.listener.internal diff --git a/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala new file mode 100644 index 0000000000..631ae35b69 --- /dev/null +++ b/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala @@ -0,0 +1,6 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.route diff --git a/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala b/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala new file mode 100644 index 0000000000..d64ed9e860 --- /dev/null +++ b/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala @@ -0,0 +1,99 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.repo.impl + +import dsp.errors.NotFoundException +import dsp.user.api.UserRepo +import dsp.user.domain.Iri +import dsp.user.domain.User +import dsp.user.domain.UserId +import zio._ +import zio.stm.TMap + +import java.util.UUID + +/** + * User repository live implementation + * + * @param users a map of users (UUID -> User). + * @param lookupTable a map of username/email to UUID. + */ +final case class UserRepoLive( + users: TMap[UUID, User], + lookupTable: TMap[String, UUID] // sealed trait for key type +) extends UserRepo { + + /** + * @inheritDoc + * + * Stores the user with key UUID in the users map. + * Stores the username and email with the associated UUID in the lookup table. + */ + def storeUser(user: User): UIO[UserId] = + (for { + _ <- users.put(user.id.uuid, user) + _ <- lookupTable.put(user.username.value, user.id.uuid) + _ <- lookupTable.put(user.email.value, user.id.uuid) + } yield user.id).commit.tap(_ => ZIO.logDebug(s"Stored user: ${user.id}")) + + /** + * @inheritDoc + */ + def getUsers(): UIO[List[User]] = users.values.commit + + /** + * @inheritDoc + */ + def getUserById(id: UserId): IO[Option[Nothing], User] = + (for { + user <- users.get(id.uuid).some + } yield user).commit.tap(_ => ZIO.logDebug(s"Found user by ID: ${id}")) + + /** + * @inheritDoc + */ + def getUserByUsernameOrEmail(usernameOrEmail: String): IO[Option[Nothing], User] = + (for { + iri <- lookupTable.get(usernameOrEmail).some + user <- users.get(iri).some + } yield user).commit.tap(_ => ZIO.logDebug(s"Found user by username or email: ${usernameOrEmail}")) + + /** + * @inheritDoc + */ + def checkUsernameOrEmailExists(usernameOrEmail: String): IO[Option[Nothing], Unit] = + (for { + iriOption: Option[UUID] <- lookupTable.get(usernameOrEmail) + _ = iriOption match { + case None => ZIO.succeed(()) // username or email does not exist + case Some(_) => ZIO.fail(None) // username or email does exist + } + } yield ()).commit.tap(_ => ZIO.logInfo(s"Username/email '${usernameOrEmail}' was checked")) + + /** + * @inheritDoc + */ + def deleteUser(id: UserId): IO[Option[Nothing], UserId] = + (for { + user <- users.get(id.uuid).some + _ <- users.delete(id.uuid) // removes the values (User) for the key (UUID) + _ <- lookupTable.delete(user.username.value) // remove the user also from the lookup table + } yield id).commit.tap(_ => ZIO.logDebug(s"Deleted user: ${id}")) +} + +/** + * Companion object providing the layer with an initialized implementation of UserRepo + */ +object UserRepoLive { + val layer: ZLayer[Any, Nothing, UserRepo] = { + ZLayer { + for { + users <- TMap.empty[UUID, User].commit + lookupTable <- TMap.empty[String, UUID].commit + } yield UserRepoLive(users, lookupTable) + }.tap(_ => ZIO.debug(">>> User repository initialized <<<")) + } +} diff --git a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala new file mode 100644 index 0000000000..b4e43dbe59 --- /dev/null +++ b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala @@ -0,0 +1,95 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.repo.impl + +import dsp.errors.BadRequestException +import dsp.user.api.UserRepo +import dsp.user.domain.User +import dsp.user.repo.impl.UserRepoLive +import dsp.user.repo.impl.UserRepoMock +import dsp.valueobjects.User._ +import zio._ +import zio.prelude.Validation +import zio.prelude.ZValidation +import zio.test._ + +/** + * This spec is used to test all [[dsp.user.repo.UserRepo]] implementations. + */ +object UserRepoImplSpec extends ZIOSpecDefault { + + def spec = (userRepoMockTests + userRepoLiveTests) + + private val testUser1 = (for { + givenName <- GivenName.make("GivenName1") + familyName <- FamilyName.make("familyName1") + username <- Username.make("username1") + email <- Email.make("email1@email.com") + password <- Password.make("password1") + language <- LanguageCode.make("en") + user = User.make( + givenName, + familyName, + username, + email, + password, + language + ) + } yield (user)).toZIO + + private val testUser2 = (for { + givenName <- GivenName.make("GivenName2") + familyName <- FamilyName.make("familyName2") + username <- Username.make("username2") + email <- Email.make("email2@email.com") + password <- Password.make("password2") + language <- LanguageCode.make("en") + user = User.make( + givenName, + familyName, + username, + email, + password, + language + ) + } yield (user)).toZIO + + val userTests = + test("store a user and retrieve by ID") { + for { + user <- testUser1 + _ <- UserRepo.storeUser(user) + retrievedUser <- UserRepo.getUserById(user.id) + } yield assertTrue(retrievedUser == user) + } + + test("retrieve the user by username") { + for { + user <- testUser1 + _ <- UserRepo.storeUser(user) + retrievedUser <- UserRepo.getUserByUsernameOrEmail(user.username.value) + } yield assertTrue(retrievedUser == user) + } + + test("retrieve the user by email") { + for { + user1 <- testUser1 + user2 <- testUser2 + _ <- UserRepo.storeUser(user1) + retrievedUser <- UserRepo.getUserByUsernameOrEmail(user1.email.value) + } yield { + assertTrue(retrievedUser == user1) && + assertTrue(retrievedUser != user2) + } + } + + val userRepoMockTests = suite("UserRepoMock")( + userTests + ).provide(UserRepoMock.layer) + + val userRepoLiveTests = suite("UserRepoLive")( + userTests + ).provide(UserRepoLive.layer) + +} diff --git a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala new file mode 100644 index 0000000000..c095f373af --- /dev/null +++ b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala @@ -0,0 +1,107 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.repo.impl + +import dsp.user.api.UserRepo +import dsp.user.domain.Iri +import dsp.user.domain.User +import dsp.user.domain.UserId +import zio._ +import zio.stm.TMap + +import java.util.UUID + +/** + * User repo test implementation. Mocks the user repo for tests. + * + * @param users a map of users (UUID -> User). + * @param lookupTable a map of users (username/email -> UUID). + */ +final case class UserRepoMock( + users: TMap[UUID, User], + lookupTable: TMap[String, UUID] // sealed trait for key type +) extends UserRepo { + + /** + * @inheritDoc + * + * Stores the user with key UUID in the users map. + * Stores the username and email with the associated UUID in the lookup table. + */ + def storeUser(user: User): UIO[UserId] = + (for { + _ <- users.put(user.id.uuid, user) + _ <- lookupTable.put(user.username.value, user.id.uuid) + _ <- lookupTable.put(user.email.value, user.id.uuid) + } yield user.id).commit.tap(_ => ZIO.logInfo(s"Stored user: ${user.id.uuid}")) + + /** + * @inheritDoc + */ + def getUsers(): UIO[List[User]] = + users.values.commit.tap(userList => ZIO.logInfo(s"Looked up all users, found ${userList.size}")) + + /** + * @inheritDoc + */ + def getUserById(id: UserId): IO[Option[Nothing], User] = + users + .get(id.uuid) + .commit + .some + .tapBoth( + _ => ZIO.logInfo(s"Couldn't find user with UUID '${id.uuid}'"), + _ => ZIO.logInfo(s"Looked up user by UUID '${id.uuid}'") + ) + + /** + * @inheritDoc + */ + def getUserByUsernameOrEmail(usernameOrEmail: String): IO[Option[Nothing], User] = + (for { + iri: UUID <- lookupTable.get(usernameOrEmail).some + user: User <- users.get(iri).some + } yield user).commit.tapBoth( + _ => ZIO.logInfo(s"Couldn't find user with username/email '${usernameOrEmail}'"), + _ => ZIO.logInfo(s"Looked up user by username/email '${usernameOrEmail}'") + ) + + /** + * @inheritDoc + */ + def checkUsernameOrEmailExists(usernameOrEmail: String): IO[Option[Nothing], Unit] = + (for { + iriOption: Option[UUID] <- lookupTable.get(usernameOrEmail) + _ = iriOption match { + case None => ZIO.succeed(()) // username or email does not exist + case Some(_) => ZIO.fail(None) // username or email does exist + } + } yield ()).commit.tap(_ => ZIO.logInfo(s"Username/email '${usernameOrEmail}' was checked")) + + /** + * @inheritDoc + */ + def deleteUser(id: UserId): IO[Option[Nothing], UserId] = + (for { + user: User <- users.get(id.uuid).some + _ <- users.delete(id.uuid) // removes the values (User) for the key (UUID) + _ <- lookupTable.delete(user.username.value) // remove the user also from the lookup table + } yield id).commit.tap(_ => ZIO.logDebug(s"Deleted user: ${id}")) +} + +/** + * Companion object providing the layer with an initialized implementation of UserRepo + */ +object UserRepoMock { + val layer: ZLayer[Any, Nothing, UserRepo] = { + ZLayer { + for { + users <- TMap.empty[UUID, User].commit + lut <- TMap.empty[String, UUID].commit + } yield UserRepoMock(users, lut) + }.tap(_ => ZIO.debug(">>> In-memory user repository initialized <<<")) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index c5d29a15bb..9a080eccb4 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -158,11 +158,21 @@ object Dependencies { zioTestSbt % Test ) + val valueObjectsLibraryDependencies = Seq( + commonsLang3, + commonsValidator, + gwtServlet, + zioPrelude, + zioTest % Test, + zioTestSbt % Test + ) + val dspApiMainLibraryDependencies = Seq( zio, zioMacros ) + // schema project dependencies val schemaApiLibraryDependencies = Seq( zioHttp ) @@ -175,14 +185,39 @@ object Dependencies { val schemaRepoEventStoreServiceLibraryDependencies = Seq() val schemaRepoSearchServiceLibraryDependencies = Seq() - val sharedLibraryDependencies = Seq( - akkaActor, - commonsLang3, - commonsValidator, - gwtServlet, - scalaLogging, - zioPrelude, + // user project dependencies + val userInterfaceLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) + val userHandlerLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) + val userCoreLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) + val userRepoLibraryDependencies = Seq( + zio, + zioMacros, zioTest % Test, zioTestSbt % Test ) + val sharedLibraryDependencies = + Seq( + commonsLang3, + commonsValidator, + gwtServlet, + zioPrelude, + scalaLogging, + zioTest % Test, + zioTestSbt % Test + ) } diff --git a/project/plugins.sbt b/project/plugins.sbt index c0480eab55..da233b8632 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,7 +3,7 @@ resolvers ++= Seq( ) // please don't remove or merge uncommented to main -addDependencyTreePlugin +//addDependencyTreePlugin addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.2") addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.9") diff --git a/webapi/src/main/scala/org/knora/webapi/app/ApplicationActor.scala b/webapi/src/main/scala/org/knora/webapi/app/ApplicationActor.scala index 57ea233336..03a1439c32 100644 --- a/webapi/src/main/scala/org/knora/webapi/app/ApplicationActor.scala +++ b/webapi/src/main/scala/org/knora/webapi/app/ApplicationActor.scala @@ -474,9 +474,10 @@ class ApplicationActor( */ def appStart(ignoreRepository: Boolean, requiresIIIFService: Boolean, retryCnt: Int): Unit = { - val bindingFuture: Future[Http.ServerBinding] = Http() - .newServerAt(knoraSettings.internalKnoraApiHost, knoraSettings.internalKnoraApiPort) - .bindFlow(Route.toFlow(apiRoutes)) + val bindingFuture: Future[Http.ServerBinding] = + Http() + .newServerAt(knoraSettings.internalKnoraApiHost, knoraSettings.internalKnoraApiPort) + .bindFlow(Route.toFlow(apiRoutes)) bindingFuture onComplete { case Success(_) =>