Commit 40f01805c8ce3486cb5b4224e06418065dbb6573
1 parent
85dcf0d6
feat(pbc-identity): User entity end-to-end (entity, repo, service, REST controll…
…er, IdentityApi adapter, unit tests)
Showing
7 changed files
with
514 additions
and
0 deletions
pbc/pbc-identity/build.gradle.kts
0 → 100644
| 1 | +plugins { | ||
| 2 | + alias(libs.plugins.kotlin.jvm) | ||
| 3 | + alias(libs.plugins.kotlin.spring) | ||
| 4 | + alias(libs.plugins.kotlin.jpa) | ||
| 5 | + alias(libs.plugins.spring.dependency.management) | ||
| 6 | +} | ||
| 7 | + | ||
| 8 | +description = "vibe_erp pbc-identity — users, roles, tenants, permissions. INTERNAL Packaged Business Capability." | ||
| 9 | + | ||
| 10 | +java { | ||
| 11 | + toolchain { | ||
| 12 | + languageVersion.set(JavaLanguageVersion.of(21)) | ||
| 13 | + } | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +kotlin { | ||
| 17 | + jvmToolchain(21) | ||
| 18 | + compilerOptions { | ||
| 19 | + freeCompilerArgs.add("-Xjsr305=strict") | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +allOpen { | ||
| 24 | + annotation("jakarta.persistence.Entity") | ||
| 25 | + annotation("jakarta.persistence.MappedSuperclass") | ||
| 26 | + annotation("jakarta.persistence.Embeddable") | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// CRITICAL: pbc-identity may depend on api-v1 and platform-* but NEVER on | ||
| 30 | +// another pbc-*. The root build.gradle.kts enforces this; if you add a | ||
| 31 | +// `:pbc:pbc-foo` dependency below, the build will refuse to load. | ||
| 32 | +dependencies { | ||
| 33 | + api(project(":api:api-v1")) | ||
| 34 | + implementation(project(":platform:platform-persistence")) | ||
| 35 | + implementation(project(":platform:platform-bootstrap")) | ||
| 36 | + | ||
| 37 | + implementation(libs.kotlin.stdlib) | ||
| 38 | + implementation(libs.kotlin.reflect) | ||
| 39 | + | ||
| 40 | + implementation(libs.spring.boot.starter) | ||
| 41 | + implementation(libs.spring.boot.starter.web) | ||
| 42 | + implementation(libs.spring.boot.starter.data.jpa) | ||
| 43 | + implementation(libs.spring.boot.starter.validation) | ||
| 44 | + implementation(libs.jackson.module.kotlin) | ||
| 45 | + | ||
| 46 | + testImplementation(libs.spring.boot.starter.test) | ||
| 47 | + testImplementation(libs.junit.jupiter) | ||
| 48 | + testImplementation(libs.assertk) | ||
| 49 | + testImplementation(libs.mockk) | ||
| 50 | + testImplementation(libs.testcontainers.postgres) | ||
| 51 | + testImplementation(libs.testcontainers.junit.jupiter) | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +tasks.test { | ||
| 55 | + useJUnitPlatform() | ||
| 56 | +} |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/UserService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.identity.application | ||
| 2 | + | ||
| 3 | +import org.springframework.stereotype.Service | ||
| 4 | +import org.springframework.transaction.annotation.Transactional | ||
| 5 | +import org.vibeerp.pbc.identity.domain.User | ||
| 6 | +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository | ||
| 7 | +import java.util.UUID | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * Application service for user CRUD. | ||
| 11 | + * | ||
| 12 | + * This is the boundary between HTTP controllers and the domain. Anything | ||
| 13 | + * a controller needs to do — list, fetch, create, update, disable — goes | ||
| 14 | + * through here. Controllers do NOT inject the repository directly; that's | ||
| 15 | + * what keeps them testable and what gives transaction boundaries one | ||
| 16 | + * obvious place to live. | ||
| 17 | + */ | ||
| 18 | +@Service | ||
| 19 | +@Transactional | ||
| 20 | +class UserService( | ||
| 21 | + private val users: UserJpaRepository, | ||
| 22 | +) { | ||
| 23 | + | ||
| 24 | + @Transactional(readOnly = true) | ||
| 25 | + fun list(): List<User> = users.findAll() | ||
| 26 | + | ||
| 27 | + @Transactional(readOnly = true) | ||
| 28 | + fun findById(id: UUID): User? = users.findById(id).orElse(null) | ||
| 29 | + | ||
| 30 | + @Transactional(readOnly = true) | ||
| 31 | + fun findByUsername(username: String): User? = users.findByUsername(username) | ||
| 32 | + | ||
| 33 | + fun create(command: CreateUserCommand): User { | ||
| 34 | + require(!users.existsByUsername(command.username)) { | ||
| 35 | + "username '${command.username}' is already taken in this tenant" | ||
| 36 | + } | ||
| 37 | + val user = User( | ||
| 38 | + username = command.username, | ||
| 39 | + displayName = command.displayName, | ||
| 40 | + email = command.email, | ||
| 41 | + enabled = command.enabled, | ||
| 42 | + ) | ||
| 43 | + return users.save(user) | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + fun update(id: UUID, command: UpdateUserCommand): User { | ||
| 47 | + val user = users.findById(id).orElseThrow { | ||
| 48 | + NoSuchElementException("user not found: $id") | ||
| 49 | + } | ||
| 50 | + command.displayName?.let { user.displayName = it } | ||
| 51 | + command.email?.let { user.email = it } | ||
| 52 | + command.enabled?.let { user.enabled = it } | ||
| 53 | + return user // managed by JPA, flushed on transaction commit | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + fun disable(id: UUID) { | ||
| 57 | + val user = users.findById(id).orElseThrow { | ||
| 58 | + NoSuchElementException("user not found: $id") | ||
| 59 | + } | ||
| 60 | + user.enabled = false | ||
| 61 | + } | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +data class CreateUserCommand( | ||
| 65 | + val username: String, | ||
| 66 | + val displayName: String, | ||
| 67 | + val email: String?, | ||
| 68 | + val enabled: Boolean = true, | ||
| 69 | +) | ||
| 70 | + | ||
| 71 | +data class UpdateUserCommand( | ||
| 72 | + val displayName: String? = null, | ||
| 73 | + val email: String? = null, | ||
| 74 | + val enabled: Boolean? = null, | ||
| 75 | +) |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/User.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.identity.domain | ||
| 2 | + | ||
| 3 | +import jakarta.persistence.Column | ||
| 4 | +import jakarta.persistence.Entity | ||
| 5 | +import jakarta.persistence.Id | ||
| 6 | +import jakarta.persistence.Table | ||
| 7 | +import org.hibernate.annotations.JdbcTypeCode | ||
| 8 | +import org.hibernate.type.SqlTypes | ||
| 9 | +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * The canonical User entity for the identity PBC. | ||
| 13 | + * | ||
| 14 | + * Why a real Hibernate `@Entity` instead of going through `Repository<User>` | ||
| 15 | + * from api.v1: PBCs live INSIDE the platform layer and are allowed to use | ||
| 16 | + * Hibernate directly. Only **plug-ins** are restricted to the api.v1 surface. | ||
| 17 | + * This is the entire point of the modular monolith — PBCs get power, plug-ins | ||
| 18 | + * get safety. | ||
| 19 | + * | ||
| 20 | + * Multi-tenancy: `tenant_id` and audit columns come from [AuditedJpaEntity] | ||
| 21 | + * and are filled in by the platform's JPA listener. PBC code never writes | ||
| 22 | + * to those columns directly. | ||
| 23 | + * | ||
| 24 | + * Custom fields: the `ext` JSONB column is the metadata-driven custom-field | ||
| 25 | + * store (architecture spec section 8). A key user can add fields to this | ||
| 26 | + * entity through the web UI without a migration; queries against custom | ||
| 27 | + * fields use the GIN index defined in the Liquibase changeset. | ||
| 28 | + */ | ||
| 29 | +@Entity | ||
| 30 | +@Table(name = "identity__user") | ||
| 31 | +class User( | ||
| 32 | + username: String, | ||
| 33 | + displayName: String, | ||
| 34 | + email: String?, | ||
| 35 | + enabled: Boolean = true, | ||
| 36 | +) : AuditedJpaEntity() { | ||
| 37 | + | ||
| 38 | + @Column(name = "username", nullable = false, length = 128) | ||
| 39 | + var username: String = username | ||
| 40 | + | ||
| 41 | + @Column(name = "display_name", nullable = false, length = 256) | ||
| 42 | + var displayName: String = displayName | ||
| 43 | + | ||
| 44 | + @Column(name = "email", nullable = true, length = 320) | ||
| 45 | + var email: String? = email | ||
| 46 | + | ||
| 47 | + @Column(name = "enabled", nullable = false) | ||
| 48 | + var enabled: Boolean = enabled | ||
| 49 | + | ||
| 50 | + /** | ||
| 51 | + * JSONB column for key-user-defined custom fields. Mapped as a String at | ||
| 52 | + * the JPA layer to keep the dependency on Hibernate's JSON mapping out | ||
| 53 | + * of api.v1; the application layer parses/serializes via Jackson when | ||
| 54 | + * the form-rendering layer demands it. | ||
| 55 | + */ | ||
| 56 | + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | ||
| 57 | + @JdbcTypeCode(SqlTypes.JSON) | ||
| 58 | + var ext: String = "{}" | ||
| 59 | + | ||
| 60 | + override fun toString(): String = | ||
| 61 | + "User(id=$id, tenantId=$tenantId, username='$username')" | ||
| 62 | +} |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/ext/IdentityApiAdapter.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.identity.ext | ||
| 2 | + | ||
| 3 | +import org.springframework.stereotype.Component | ||
| 4 | +import org.springframework.transaction.annotation.Transactional | ||
| 5 | +import org.vibeerp.api.v1.core.Id | ||
| 6 | +import org.vibeerp.api.v1.core.TenantId | ||
| 7 | +import org.vibeerp.api.v1.ext.identity.IdentityApi | ||
| 8 | +import org.vibeerp.api.v1.ext.identity.UserRef | ||
| 9 | +import org.vibeerp.pbc.identity.domain.User | ||
| 10 | +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository | ||
| 11 | +import java.util.UUID | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * Concrete [IdentityApi] implementation. The ONLY thing other PBCs and | ||
| 15 | + * plug-ins are allowed to inject for "give me a user reference". | ||
| 16 | + * | ||
| 17 | + * Why this file exists at all: see the rationale on [IdentityApi]. The | ||
| 18 | + * Gradle build forbids cross-PBC dependencies; this adapter is the | ||
| 19 | + * runtime fulfillment of the api.v1 contract that lets the rest of the | ||
| 20 | + * world ask identity questions without importing identity types. | ||
| 21 | + * | ||
| 22 | + * **What this adapter MUST NOT do:** | ||
| 23 | + * • leak `org.vibeerp.pbc.identity.domain.User` to callers | ||
| 24 | + * • bypass tenant filtering (caller's tenant context is honored automatically) | ||
| 25 | + * • return PII fields that aren't already on `UserRef` | ||
| 26 | + * | ||
| 27 | + * If a caller needs more fields, the answer is to grow `UserRef` deliberately | ||
| 28 | + * (an api.v1 change with a major-version cost), not to hand them the entity. | ||
| 29 | + */ | ||
| 30 | +@Component | ||
| 31 | +@Transactional(readOnly = true) | ||
| 32 | +class IdentityApiAdapter( | ||
| 33 | + private val users: UserJpaRepository, | ||
| 34 | +) : IdentityApi { | ||
| 35 | + | ||
| 36 | + override fun findUserByUsername(username: String): UserRef? = | ||
| 37 | + users.findByUsername(username)?.toRef() | ||
| 38 | + | ||
| 39 | + override fun findUserById(id: Id<*>): UserRef? = | ||
| 40 | + users.findById(id.value).orElse(null)?.toRef() | ||
| 41 | + | ||
| 42 | + private fun User.toRef(): UserRef = UserRef( | ||
| 43 | + id = Id<UserRef>(this.id), | ||
| 44 | + username = this.username, | ||
| 45 | + tenantId = TenantId(this.tenantId), | ||
| 46 | + displayName = this.displayName, | ||
| 47 | + enabled = this.enabled, | ||
| 48 | + ) | ||
| 49 | +} |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/UserController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.identity.http | ||
| 2 | + | ||
| 3 | +import jakarta.validation.Valid | ||
| 4 | +import jakarta.validation.constraints.Email | ||
| 5 | +import jakarta.validation.constraints.NotBlank | ||
| 6 | +import jakarta.validation.constraints.Size | ||
| 7 | +import org.springframework.http.HttpStatus | ||
| 8 | +import org.springframework.http.ResponseEntity | ||
| 9 | +import org.springframework.web.bind.annotation.DeleteMapping | ||
| 10 | +import org.springframework.web.bind.annotation.GetMapping | ||
| 11 | +import org.springframework.web.bind.annotation.PatchMapping | ||
| 12 | +import org.springframework.web.bind.annotation.PathVariable | ||
| 13 | +import org.springframework.web.bind.annotation.PostMapping | ||
| 14 | +import org.springframework.web.bind.annotation.RequestBody | ||
| 15 | +import org.springframework.web.bind.annotation.RequestMapping | ||
| 16 | +import org.springframework.web.bind.annotation.ResponseStatus | ||
| 17 | +import org.springframework.web.bind.annotation.RestController | ||
| 18 | +import org.vibeerp.pbc.identity.application.CreateUserCommand | ||
| 19 | +import org.vibeerp.pbc.identity.application.UpdateUserCommand | ||
| 20 | +import org.vibeerp.pbc.identity.application.UserService | ||
| 21 | +import org.vibeerp.pbc.identity.domain.User | ||
| 22 | +import java.util.UUID | ||
| 23 | + | ||
| 24 | +/** | ||
| 25 | + * REST API for the identity PBC's User aggregate. | ||
| 26 | + * | ||
| 27 | + * Mounted at `/api/v1/identity/users`. The path follows the convention | ||
| 28 | + * `/api/v1/<pbc-name>/<resource>` so the surface naturally namespaces by | ||
| 29 | + * PBC, and so plug-in endpoints (which mount under `/api/v1/plugins/...`) | ||
| 30 | + * cannot collide with core endpoints. | ||
| 31 | + * | ||
| 32 | + * Tenant id is bound automatically by `TenantResolutionFilter` upstream of | ||
| 33 | + * this controller — there is no `tenantId` parameter on any endpoint and | ||
| 34 | + * there never will be. Cross-tenant access is impossible by construction. | ||
| 35 | + * | ||
| 36 | + * Authentication is deferred to v0.2 (pbc-identity's auth flow). For v0.1 | ||
| 37 | + * the controller answers any request that gets past the tenant filter. | ||
| 38 | + */ | ||
| 39 | +@RestController | ||
| 40 | +@RequestMapping("/api/v1/identity/users") | ||
| 41 | +class UserController( | ||
| 42 | + private val userService: UserService, | ||
| 43 | +) { | ||
| 44 | + | ||
| 45 | + @GetMapping | ||
| 46 | + fun list(): List<UserResponse> = | ||
| 47 | + userService.list().map { it.toResponse() } | ||
| 48 | + | ||
| 49 | + @GetMapping("/{id}") | ||
| 50 | + fun get(@PathVariable id: UUID): ResponseEntity<UserResponse> { | ||
| 51 | + val user = userService.findById(id) ?: return ResponseEntity.notFound().build() | ||
| 52 | + return ResponseEntity.ok(user.toResponse()) | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @PostMapping | ||
| 56 | + @ResponseStatus(HttpStatus.CREATED) | ||
| 57 | + fun create(@RequestBody @Valid request: CreateUserRequest): UserResponse = | ||
| 58 | + userService.create( | ||
| 59 | + CreateUserCommand( | ||
| 60 | + username = request.username, | ||
| 61 | + displayName = request.displayName, | ||
| 62 | + email = request.email, | ||
| 63 | + enabled = request.enabled ?: true, | ||
| 64 | + ), | ||
| 65 | + ).toResponse() | ||
| 66 | + | ||
| 67 | + @PatchMapping("/{id}") | ||
| 68 | + fun update( | ||
| 69 | + @PathVariable id: UUID, | ||
| 70 | + @RequestBody @Valid request: UpdateUserRequest, | ||
| 71 | + ): UserResponse = | ||
| 72 | + userService.update( | ||
| 73 | + id, | ||
| 74 | + UpdateUserCommand( | ||
| 75 | + displayName = request.displayName, | ||
| 76 | + email = request.email, | ||
| 77 | + enabled = request.enabled, | ||
| 78 | + ), | ||
| 79 | + ).toResponse() | ||
| 80 | + | ||
| 81 | + @DeleteMapping("/{id}") | ||
| 82 | + @ResponseStatus(HttpStatus.NO_CONTENT) | ||
| 83 | + fun disable(@PathVariable id: UUID) { | ||
| 84 | + userService.disable(id) | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// ─── DTOs ──────────────────────────────────────────────────────────── | ||
| 89 | + | ||
| 90 | +data class CreateUserRequest( | ||
| 91 | + @field:NotBlank @field:Size(max = 128) val username: String, | ||
| 92 | + @field:NotBlank @field:Size(max = 256) val displayName: String, | ||
| 93 | + @field:Email @field:Size(max = 320) val email: String?, | ||
| 94 | + val enabled: Boolean? = true, | ||
| 95 | +) | ||
| 96 | + | ||
| 97 | +data class UpdateUserRequest( | ||
| 98 | + @field:Size(max = 256) val displayName: String? = null, | ||
| 99 | + @field:Email @field:Size(max = 320) val email: String? = null, | ||
| 100 | + val enabled: Boolean? = null, | ||
| 101 | +) | ||
| 102 | + | ||
| 103 | +data class UserResponse( | ||
| 104 | + val id: UUID, | ||
| 105 | + val tenantId: String, | ||
| 106 | + val username: String, | ||
| 107 | + val displayName: String, | ||
| 108 | + val email: String?, | ||
| 109 | + val enabled: Boolean, | ||
| 110 | +) | ||
| 111 | + | ||
| 112 | +private fun User.toResponse() = UserResponse( | ||
| 113 | + id = this.id, | ||
| 114 | + tenantId = this.tenantId, | ||
| 115 | + username = this.username, | ||
| 116 | + displayName = this.displayName, | ||
| 117 | + email = this.email, | ||
| 118 | + enabled = this.enabled, | ||
| 119 | +) |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.identity.infrastructure | ||
| 2 | + | ||
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | ||
| 4 | +import org.springframework.stereotype.Repository | ||
| 5 | +import org.vibeerp.pbc.identity.domain.User | ||
| 6 | +import java.util.UUID | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * Spring Data JPA repository for [User]. | ||
| 10 | + * | ||
| 11 | + * Tenant filtering happens automatically: Hibernate's | ||
| 12 | + * [org.vibeerp.platform.persistence.tenancy.HibernateTenantResolver] sees the | ||
| 13 | + * tenant from `TenantContext` and adds the discriminator predicate to every | ||
| 14 | + * generated query. Code in this file never writes `WHERE tenant_id = ?`. | ||
| 15 | + * | ||
| 16 | + * The Postgres Row-Level Security policy on `identity__user` is the second | ||
| 17 | + * wall — even if Hibernate's filter were misconfigured, the database would | ||
| 18 | + * still refuse to return cross-tenant rows. | ||
| 19 | + */ | ||
| 20 | +@Repository | ||
| 21 | +interface UserJpaRepository : JpaRepository<User, UUID> { | ||
| 22 | + | ||
| 23 | + fun findByUsername(username: String): User? | ||
| 24 | + | ||
| 25 | + fun existsByUsername(username: String): Boolean | ||
| 26 | +} |
pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/UserServiceTest.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.identity.application | ||
| 2 | + | ||
| 3 | +import assertk.assertFailure | ||
| 4 | +import assertk.assertThat | ||
| 5 | +import assertk.assertions.hasMessage | ||
| 6 | +import assertk.assertions.isEqualTo | ||
| 7 | +import assertk.assertions.isInstanceOf | ||
| 8 | +import assertk.assertions.isNotNull | ||
| 9 | +import assertk.assertions.isNull | ||
| 10 | +import io.mockk.every | ||
| 11 | +import io.mockk.mockk | ||
| 12 | +import io.mockk.slot | ||
| 13 | +import io.mockk.verify | ||
| 14 | +import org.junit.jupiter.api.BeforeEach | ||
| 15 | +import org.junit.jupiter.api.Test | ||
| 16 | +import org.vibeerp.pbc.identity.domain.User | ||
| 17 | +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository | ||
| 18 | +import java.util.Optional | ||
| 19 | +import java.util.UUID | ||
| 20 | + | ||
| 21 | +/** | ||
| 22 | + * Pure unit tests for [UserService] — no Spring, no database. Reach into | ||
| 23 | + * the JPA repository through a mock. | ||
| 24 | + * | ||
| 25 | + * Why mock the repository instead of using Testcontainers Postgres here: | ||
| 26 | + * • these tests run in milliseconds in every developer's IDE | ||
| 27 | + * • the repository's correctness (multi-tenant filtering, RLS) is | ||
| 28 | + * covered by an integration test elsewhere — `UserPersistenceIT` | ||
| 29 | + * (lands in v0.2 alongside the platform-persistence test harness) | ||
| 30 | + */ | ||
| 31 | +class UserServiceTest { | ||
| 32 | + | ||
| 33 | + private lateinit var users: UserJpaRepository | ||
| 34 | + private lateinit var service: UserService | ||
| 35 | + | ||
| 36 | + @BeforeEach | ||
| 37 | + fun setUp() { | ||
| 38 | + users = mockk(relaxed = false) | ||
| 39 | + service = UserService(users) | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + @Test | ||
| 43 | + fun `create rejects duplicate username in the same tenant`() { | ||
| 44 | + every { users.existsByUsername("alice") } returns true | ||
| 45 | + | ||
| 46 | + assertFailure { | ||
| 47 | + service.create( | ||
| 48 | + CreateUserCommand( | ||
| 49 | + username = "alice", | ||
| 50 | + displayName = "Alice", | ||
| 51 | + email = null, | ||
| 52 | + ), | ||
| 53 | + ) | ||
| 54 | + } | ||
| 55 | + .isInstanceOf(IllegalArgumentException::class) | ||
| 56 | + .hasMessage("username 'alice' is already taken in this tenant") | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + @Test | ||
| 60 | + fun `create persists a new user when the username is free`() { | ||
| 61 | + val saved = slot<User>() | ||
| 62 | + every { users.existsByUsername("bob") } returns false | ||
| 63 | + every { users.save(capture(saved)) } answers { saved.captured } | ||
| 64 | + | ||
| 65 | + val result = service.create( | ||
| 66 | + CreateUserCommand( | ||
| 67 | + username = "bob", | ||
| 68 | + displayName = "Bob the Builder", | ||
| 69 | + email = "bob@example.com", | ||
| 70 | + ), | ||
| 71 | + ) | ||
| 72 | + | ||
| 73 | + assertThat(result.username).isEqualTo("bob") | ||
| 74 | + assertThat(result.displayName).isEqualTo("Bob the Builder") | ||
| 75 | + assertThat(result.email).isEqualTo("bob@example.com") | ||
| 76 | + assertThat(result.enabled).isEqualTo(true) | ||
| 77 | + verify(exactly = 1) { users.save(any()) } | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + @Test | ||
| 81 | + fun `findByUsername returns null when no user exists`() { | ||
| 82 | + every { users.findByUsername("ghost") } returns null | ||
| 83 | + | ||
| 84 | + assertThat(service.findByUsername("ghost")).isNull() | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + @Test | ||
| 88 | + fun `update mutates only the supplied fields`() { | ||
| 89 | + val id = UUID.randomUUID() | ||
| 90 | + val existing = User( | ||
| 91 | + username = "carol", | ||
| 92 | + displayName = "Carol", | ||
| 93 | + email = "carol@old.example", | ||
| 94 | + enabled = true, | ||
| 95 | + ).also { it.id = id } | ||
| 96 | + every { users.findById(id) } returns Optional.of(existing) | ||
| 97 | + | ||
| 98 | + val updated = service.update( | ||
| 99 | + id = id, | ||
| 100 | + command = UpdateUserCommand(displayName = "Carol Danvers"), | ||
| 101 | + ) | ||
| 102 | + | ||
| 103 | + assertThat(updated.displayName).isEqualTo("Carol Danvers") | ||
| 104 | + assertThat(updated.email).isEqualTo("carol@old.example") // untouched | ||
| 105 | + assertThat(updated.enabled).isEqualTo(true) // untouched | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + @Test | ||
| 109 | + fun `disable flips the enabled flag without deleting the row`() { | ||
| 110 | + val id = UUID.randomUUID() | ||
| 111 | + val existing = User("dave", "Dave", null, enabled = true).also { it.id = id } | ||
| 112 | + every { users.findById(id) } returns Optional.of(existing) | ||
| 113 | + | ||
| 114 | + service.disable(id) | ||
| 115 | + | ||
| 116 | + assertThat(existing.enabled).isEqualTo(false) | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + @Test | ||
| 120 | + fun `update throws NoSuchElementException when the user is missing`() { | ||
| 121 | + val id = UUID.randomUUID() | ||
| 122 | + every { users.findById(id) } returns Optional.empty() | ||
| 123 | + | ||
| 124 | + assertFailure { service.update(id, UpdateUserCommand(displayName = "Eve")) } | ||
| 125 | + .isInstanceOf(NoSuchElementException::class) | ||
| 126 | + } | ||
| 127 | +} |