#29 Implement Workgroup (route, voter, repo, entity)
Create tests for workgroup routes add CitizenWithUserI
This commit is contained in:
@@ -45,11 +45,13 @@ import org.slf4j.event.Level
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletionException
|
||||
import fr.dcproject.entity.Workgroup as WorkgroupEntity
|
||||
import fr.dcproject.repository.Article as RepositoryArticle
|
||||
import fr.dcproject.repository.Citizen as RepositoryCitizen
|
||||
import fr.dcproject.repository.Constitution as RepositoryConstitution
|
||||
import fr.dcproject.repository.OpinionChoice as OpinionChoiceRepository
|
||||
import fr.dcproject.repository.User as UserRepository
|
||||
import fr.dcproject.repository.Workgroup as WorkgroupRepository
|
||||
|
||||
fun main(args: Array<String>): Unit = io.ktor.server.jetty.EngineMain.main(args)
|
||||
|
||||
@@ -97,7 +99,7 @@ fun Application.module(env: Env = PROD) {
|
||||
decode { values, _ ->
|
||||
values.singleOrNull()?.let {
|
||||
ArticleRef(UUID.fromString(it))
|
||||
} ?: throw NotFoundException("Article $values not found")
|
||||
} ?: throw NotFoundException("""UUID "$values" is not valid for Article""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,14 +107,14 @@ fun Application.module(env: Env = PROD) {
|
||||
decode { values, _ ->
|
||||
values.singleOrNull()?.let {
|
||||
CommentRef(UUID.fromString(it))
|
||||
} ?: throw NotFoundException("Comment $values not found")
|
||||
} ?: throw NotFoundException("""UUID "$values" is not valid for Comment""")
|
||||
}
|
||||
}
|
||||
convert<ConstitutionRef> {
|
||||
decode { values, _ ->
|
||||
values.singleOrNull()?.let {
|
||||
ConstitutionRef(UUID.fromString(it))
|
||||
} ?: throw NotFoundException("Constitution $values not found")
|
||||
} ?: throw NotFoundException("""UUID "$values" is not valid for Constitution""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ fun Application.module(env: Env = PROD) {
|
||||
decode { values, _ ->
|
||||
values.singleOrNull()?.let {
|
||||
CitizenRef(UUID.fromString(it))
|
||||
} ?: throw NotFoundException("Citizen $values not found")
|
||||
} ?: throw NotFoundException("""UUID "$values" is not valid for Citizen""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +150,23 @@ fun Application.module(env: Env = PROD) {
|
||||
?: throw NotFoundException("OpinionChoice $values not found")
|
||||
}
|
||||
}
|
||||
|
||||
convert<CitizenRef> {
|
||||
decode { values, _ ->
|
||||
values.singleOrNull()?.let {
|
||||
CitizenRef(UUID.fromString(it))
|
||||
} ?: throw NotFoundException("""UUID "$values" is not valid for Workgroup""")
|
||||
}
|
||||
}
|
||||
|
||||
convert<WorkgroupEntity> {
|
||||
decode { values, _ ->
|
||||
val id = values.singleOrNull()?.let { UUID.fromString(it) }
|
||||
?: throw InternalError("Cannot convert $values to UUID")
|
||||
get<WorkgroupRepository>().findById(id)
|
||||
?: throw NotFoundException("Workgroup $values not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install(Locations) {
|
||||
@@ -162,7 +181,8 @@ fun Application.module(env: Env = PROD) {
|
||||
VoteVoter(),
|
||||
FollowVoter(),
|
||||
OpinionVoter(),
|
||||
OpinionChoiceVoter()
|
||||
OpinionChoiceVoter(),
|
||||
WorkgroupVoter()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +319,7 @@ fun Application.module(env: Env = PROD) {
|
||||
voteConstitution(get())
|
||||
opinionArticle(get())
|
||||
opinionChoice(get())
|
||||
workgroup(get())
|
||||
definition()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import fr.dcproject.repository.User as UserRepository
|
||||
import fr.dcproject.repository.VoteArticle as VoteArticleRepository
|
||||
import fr.dcproject.repository.VoteComment as VoteCommentRepository
|
||||
import fr.dcproject.repository.VoteConstitution as VoteConstitutionRepository
|
||||
import fr.dcproject.repository.Workgroup as WorkgroupRepository
|
||||
|
||||
val config = Config()
|
||||
|
||||
@@ -111,6 +112,7 @@ val Module = module {
|
||||
single { VoteCommentRepository(get()) }
|
||||
single { OpinionChoiceRepository(get()) }
|
||||
single { OpinionArticleRepository(get()) }
|
||||
single { WorkgroupRepository(get()) }
|
||||
|
||||
// Elasticsearch Client
|
||||
single<RestClient> {
|
||||
|
||||
@@ -41,8 +41,9 @@ open class CitizenSimple(
|
||||
|
||||
open class CitizenRefWithUser(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
open val user: UserRef
|
||||
) : CitizenRef(id),
|
||||
override val user: UserRef
|
||||
) : CitizenWithUserI,
|
||||
CitizenRef(id),
|
||||
EntityDeletedAt by EntityDeletedAtImp()
|
||||
|
||||
open class CitizenRef(
|
||||
@@ -58,15 +59,18 @@ interface CitizenI : UuidEntityI {
|
||||
)
|
||||
}
|
||||
|
||||
interface CitizenBasicI : CitizenI, EntityDeletedAt {
|
||||
interface CitizenBasicI : CitizenWithUserI, EntityDeletedAt {
|
||||
var name: Name
|
||||
var email: String
|
||||
var birthday: DateTime
|
||||
var voteAnonymous: Boolean
|
||||
var followAnonymous: Boolean
|
||||
val user: UserI
|
||||
}
|
||||
|
||||
interface CitizenFull : CitizenBasicI {
|
||||
override val user: User
|
||||
}
|
||||
|
||||
interface CitizenWithUserI : CitizenI {
|
||||
val user: UserI
|
||||
}
|
||||
|
||||
58
src/main/kotlin/fr/dcproject/entity/Workgroup.kt
Normal file
58
src/main/kotlin/fr/dcproject/entity/Workgroup.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
package fr.dcproject.entity
|
||||
|
||||
import fr.postgresjson.entity.immutable.*
|
||||
import fr.postgresjson.entity.mutable.EntityDeletedAt
|
||||
import fr.postgresjson.entity.mutable.EntityDeletedAtImp
|
||||
import java.util.*
|
||||
|
||||
class Workgroup(
|
||||
id: UUID?,
|
||||
name: String,
|
||||
description: String,
|
||||
logo: String? = null,
|
||||
anonymous: Boolean = true,
|
||||
owner: CitizenBasic,
|
||||
createdBy: CitizenBasic,
|
||||
override var members: List<CitizenBasic> = emptyList()
|
||||
) : WorkgroupWithAuthI<CitizenBasic>,
|
||||
WorkgroupSimple<CitizenBasic>(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
logo,
|
||||
anonymous,
|
||||
owner,
|
||||
createdBy
|
||||
),
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityUpdatedAt by EntityUpdatedAtImp()
|
||||
|
||||
open class WorkgroupSimple<Z : CitizenRef>(
|
||||
id: UUID?,
|
||||
var name: String,
|
||||
var description: String,
|
||||
var logo: String? = null,
|
||||
var anonymous: Boolean = true,
|
||||
var owner: Z,
|
||||
createdBy: Z
|
||||
) : WorkgroupRef(id),
|
||||
EntityCreatedBy<Z> by EntityCreatedByImp(createdBy),
|
||||
EntityDeletedAt by EntityDeletedAtImp()
|
||||
|
||||
open class WorkgroupRef(
|
||||
id: UUID?
|
||||
) : UuidEntity(id ?: UUID.randomUUID()), WorkgroupI
|
||||
|
||||
interface WorkgroupWithAuthI<Z : CitizenWithUserI> : WorkgroupI, EntityCreatedBy<Z>, EntityDeletedAt {
|
||||
val anonymous: Boolean
|
||||
val owner: Z
|
||||
var members: List<Z>
|
||||
|
||||
fun isMember(user: UserI): Boolean =
|
||||
members.map { it.user.id }.contains(user.id) || owner.user.id == user.id
|
||||
|
||||
fun isMember(citizen: CitizenWithUserI): Boolean =
|
||||
isMember(citizen.user)
|
||||
}
|
||||
|
||||
interface WorkgroupI : UuidEntityI
|
||||
46
src/main/kotlin/fr/dcproject/repository/Workgroup.kt
Normal file
46
src/main/kotlin/fr/dcproject/repository/Workgroup.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package fr.dcproject.repository
|
||||
|
||||
import fr.dcproject.entity.CitizenRef
|
||||
import fr.dcproject.entity.WorkgroupSimple
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.entity.Parameter
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import fr.postgresjson.repository.RepositoryI.Direction
|
||||
import net.pearx.kasechange.toSnakeCase
|
||||
import java.util.*
|
||||
import fr.dcproject.entity.Workgroup as WorkgroupEntity
|
||||
|
||||
class Workgroup(override var requester: Requester) : RepositoryI {
|
||||
fun findById(id: UUID): WorkgroupEntity? {
|
||||
val function = requester.getFunction("find_workgroup_by_id")
|
||||
return function.selectOne("id" to id)
|
||||
}
|
||||
|
||||
fun find(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
sort: String? = null,
|
||||
direction: Direction? = null,
|
||||
search: String? = null,
|
||||
filter: Filter = Filter()
|
||||
): Paginated<WorkgroupEntity> {
|
||||
return requester
|
||||
.getFunction("find_workgroups")
|
||||
.select(
|
||||
page, limit,
|
||||
"sort" to sort?.toSnakeCase(),
|
||||
"direction" to direction,
|
||||
"search" to search,
|
||||
"filter" to filter
|
||||
)
|
||||
}
|
||||
|
||||
fun upsert(workgroup: WorkgroupSimple<CitizenRef>): WorkgroupEntity = requester
|
||||
.getFunction("upsert_workgroup")
|
||||
.selectOne("resource" to workgroup) ?: error("query 'upsert_workgroup' return null")
|
||||
|
||||
class Filter(
|
||||
val createdById: String? = null
|
||||
) : Parameter
|
||||
}
|
||||
99
src/main/kotlin/fr/dcproject/routes/Workgroup.kt
Normal file
99
src/main/kotlin/fr/dcproject/routes/Workgroup.kt
Normal file
@@ -0,0 +1,99 @@
|
||||
package fr.dcproject.routes
|
||||
|
||||
import fr.dcproject.citizen
|
||||
import fr.dcproject.entity.CitizenRef
|
||||
import fr.dcproject.entity.WorkgroupSimple
|
||||
import fr.dcproject.entity.request.RequestBuilder
|
||||
import fr.dcproject.entity.request.getContent
|
||||
import fr.dcproject.repository.Workgroup.Filter
|
||||
import fr.dcproject.security.voter.WorkgroupVoter.Action.VIEW
|
||||
import fr.dcproject.security.voter.WorkgroupVoter.Action.CREATE
|
||||
import fr.dcproject.security.voter.assertCan
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.request.receive
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import org.koin.core.KoinComponent
|
||||
import java.util.*
|
||||
import fr.dcproject.entity.Workgroup as WorkgroupEntity
|
||||
import fr.dcproject.repository.Workgroup as WorkgroupRepository
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object WorkgroupsPaths {
|
||||
@Location("/workgroups")
|
||||
class WorkgroupsRequest(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val sort: String? = null,
|
||||
val direction: RepositoryI.Direction? = null,
|
||||
val search: String? = null,
|
||||
val createdBy: String? = null
|
||||
) {
|
||||
val page: Int = if (page < 1) 1 else page
|
||||
val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
|
||||
}
|
||||
|
||||
@Location("/workgroups/{workgroup}")
|
||||
class WorkgroupRequest(val workgroup: WorkgroupEntity)
|
||||
|
||||
@Location("/workgroups")
|
||||
class PostWorkgroupRequest : RequestBuilder<WorkgroupSimple<CitizenRef>> {
|
||||
class Content(
|
||||
val id: UUID?,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val logo: String?,
|
||||
val anonymous: Boolean?,
|
||||
val owner: CitizenRef?
|
||||
) : KoinComponent {
|
||||
fun create(creator: CitizenRef): WorkgroupSimple<CitizenRef> {
|
||||
return WorkgroupSimple(
|
||||
id ?: UUID.randomUUID(),
|
||||
name,
|
||||
description,
|
||||
logo,
|
||||
anonymous ?: true,
|
||||
owner ?: creator,
|
||||
creator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getContent(call: ApplicationCall): WorkgroupSimple<CitizenRef> {
|
||||
return call.receive<Content>().create(call.citizen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Route.workgroup(repo: WorkgroupRepository) {
|
||||
get<WorkgroupsPaths.WorkgroupsRequest> {
|
||||
val workgroups =
|
||||
repo.find(it.page, it.limit, it.sort, it.direction, it.search, Filter(createdById = it.createdBy))
|
||||
assertCan(VIEW, workgroups.result)
|
||||
call.respond(workgroups)
|
||||
}
|
||||
|
||||
get<WorkgroupsPaths.WorkgroupRequest> {
|
||||
assertCan(VIEW, it.workgroup)
|
||||
|
||||
call.respond(it.workgroup)
|
||||
}
|
||||
|
||||
post<WorkgroupsPaths.PostWorkgroupRequest> {
|
||||
call.getContent(it)
|
||||
.let { workgroup ->
|
||||
assertCan(CREATE, workgroup)
|
||||
repo.upsert(workgroup)
|
||||
}.let {
|
||||
call.respond(HttpStatusCode.Created, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package fr.dcproject.security.voter
|
||||
|
||||
import fr.dcproject.entity.UserI
|
||||
import fr.dcproject.entity.WorkgroupI
|
||||
import fr.dcproject.entity.WorkgroupWithAuthI
|
||||
import io.ktor.application.ApplicationCall
|
||||
|
||||
class WorkgroupVoter : Voter {
|
||||
enum class Action : ActionI {
|
||||
CREATE,
|
||||
UPDATE,
|
||||
VIEW,
|
||||
DELETE
|
||||
}
|
||||
|
||||
override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean {
|
||||
return (action is Action)
|
||||
.and(subject is List<*> || subject is WorkgroupI?)
|
||||
}
|
||||
|
||||
override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote {
|
||||
val user = call.user
|
||||
if (subject is WorkgroupI && action == Action.CREATE && user is UserI) {
|
||||
return Vote.GRANTED
|
||||
}
|
||||
|
||||
if (action == Action.VIEW) {
|
||||
if (subject is WorkgroupWithAuthI<*>) {
|
||||
return if (subject.isDeleted()) Vote.DENIED
|
||||
else if (!subject.anonymous) Vote.GRANTED
|
||||
else if (subject.anonymous && user != null && subject.isMember(user)) Vote.GRANTED
|
||||
else Vote.DENIED
|
||||
}
|
||||
if (subject is List<*>) {
|
||||
subject.forEach {
|
||||
if (it !is WorkgroupWithAuthI<*> || it.isDeleted()) {
|
||||
return Vote.DENIED
|
||||
}
|
||||
}
|
||||
return Vote.GRANTED
|
||||
}
|
||||
return Vote.DENIED
|
||||
}
|
||||
|
||||
if (subject is WorkgroupWithAuthI<*>) {
|
||||
if (action == Action.DELETE && user is UserI && subject.owner.user.id == user.id) {
|
||||
return Vote.GRANTED
|
||||
}
|
||||
|
||||
if (action == Action.UPDATE && user is UserI && subject.owner.user.id == user.id) {
|
||||
return Vote.GRANTED
|
||||
}
|
||||
|
||||
return Vote.DENIED
|
||||
}
|
||||
|
||||
if (action is Action) {
|
||||
return Vote.DENIED
|
||||
}
|
||||
|
||||
return Vote.ABSTAIN
|
||||
}
|
||||
}
|
||||
53
src/test/kotlin/feature/WorkgroupSteps.kt
Normal file
53
src/test/kotlin/feature/WorkgroupSteps.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
package feature
|
||||
|
||||
import fr.dcproject.entity.*
|
||||
import io.cucumber.datatable.DataTable
|
||||
import io.cucumber.java8.En
|
||||
import org.joda.time.DateTime
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.get
|
||||
import java.util.*
|
||||
import fr.dcproject.repository.Citizen as CitizenRepository
|
||||
import fr.dcproject.repository.Workgroup as WorkgroupRepository
|
||||
|
||||
class WorkgroupSteps : En, KoinTest {
|
||||
init {
|
||||
When("I have workgroup:") { body: DataTable ->
|
||||
val data = body.asMap<String, String>(String::class.java, String::class.java)
|
||||
|
||||
val creator = if (data["created_by"] != null) {
|
||||
CitizenRef(UUID.fromString(data["created_by"]))
|
||||
} else {
|
||||
val username = "paul-langevin".toLowerCase() + UUID.randomUUID()
|
||||
val user = User(
|
||||
username = username,
|
||||
plainPassword = "azerty"
|
||||
)
|
||||
Citizen(
|
||||
name = CitizenI.Name("Paul", "Langevin"),
|
||||
email = "$username@dc-project.fr",
|
||||
birthday = DateTime.now(),
|
||||
user = user
|
||||
).also {
|
||||
get<CitizenRepository>().insertWithUser(it)
|
||||
}
|
||||
}
|
||||
val owner = if (data["owner"] != null) {
|
||||
CitizenRef(UUID.fromString(data["owner"]))
|
||||
} else {
|
||||
creator
|
||||
}
|
||||
|
||||
val workgroup = WorkgroupSimple(
|
||||
id = UUID.fromString(data["id"] ?: UUID.randomUUID().toString()),
|
||||
name = data["name"] ?: "Les Incoruptible",
|
||||
description = data["description"] ?: "La vie est notre jeux",
|
||||
createdBy = creator,
|
||||
owner = owner,
|
||||
anonymous = (data["anonymous"] ?: false) == true
|
||||
)
|
||||
|
||||
get<WorkgroupRepository>().upsert(workgroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/test/resources/feature/workgroup.feature
Normal file
44
src/test/resources/feature/workgroup.feature
Normal file
@@ -0,0 +1,44 @@
|
||||
@workgroup
|
||||
Feature: Workgroup
|
||||
|
||||
Scenario: Can get one workgroup
|
||||
Given I have citizen Stephen Hawking
|
||||
And I am authenticated as Stephen Hawking
|
||||
And I have workgroup:
|
||||
| id | ab469134-bf14-4856-b093-ae1aa990f977 |
|
||||
| name | Les Mousquets |
|
||||
When I send a GET request to "/workgroups/ab469134-bf14-4856-b093-ae1aa990f977"
|
||||
Then the response status code should be 200
|
||||
And the JSON should contain:
|
||||
| id | ab469134-bf14-4856-b093-ae1aa990f977 |
|
||||
| name | Les Mousquets |
|
||||
|
||||
Scenario: Can create a workgroup
|
||||
Given I have citizen Werner Heisenberg
|
||||
And I am authenticated as Werner Heisenberg
|
||||
When I send a POST request to "/workgroups" with body:
|
||||
"""
|
||||
{
|
||||
"id":"f496d86d-6654-4068-91ff-90e1dbcc5f38",
|
||||
"name":"Les Bouffons",
|
||||
"description":"La vie est belle",
|
||||
"anonymous":false
|
||||
}
|
||||
"""
|
||||
Then the response status code should be 201
|
||||
And the JSON should contain:
|
||||
| id | f496d86d-6654-4068-91ff-90e1dbcc5f38 |
|
||||
| name | Les Bouffons |
|
||||
| description | La vie est belle |
|
||||
| anonymous | false |
|
||||
|
||||
Scenario: Can get workgroups list
|
||||
Given I have citizen Max Planck
|
||||
And I am authenticated as Max Planck
|
||||
And I have workgroup:
|
||||
| id | 3fd8edb6-c4b4-4c94-bc75-ddd9b290d32c |
|
||||
| name | Les Pissenlits |
|
||||
When I send a GET request to "/workgroups"
|
||||
Then the response status code should be 200
|
||||
And the response should contain object:
|
||||
| $.result[0]id | 3fd8edb6-c4b4-4c94-bc75-ddd9b290d32c |
|
||||
Reference in New Issue
Block a user