From 78dd11ce1d788df248c425fb615293a26a6a3ab8 Mon Sep 17 00:00:00 2001 From: Cesar Ramirez Date: Wed, 25 Mar 2026 07:29:42 -0700 Subject: [PATCH 1/2] =?UTF-8?q?[EDIFIKANA]=20#386=20Unit=20CRUD=20API=20?= =?UTF-8?q?=E2=80=94=20shared=20models,=20API=20contract,=20and=20back-end?= =?UTF-8?q?=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #386 (MDL-01a, API-01a, BE-01a) covering the full vertical slice for unit management: shared library models, API operation definitions, and the complete back-end controller/service/datastore stack. ## Shared library (edifikana/shared) - Add `UnitModel` — client-facing domain model with all unit fields - Add `GetUnitsQueryParams` — query params (org_id, optional property_id) for the list-units endpoint ## API contract (edifikana/api) - Add `UnitApi` — defines five typed operations: createUnit (POST), getUnit (GET /{id}), getUnits (GET ?org_id=&property_id=), updateUnit (PUT /{id}), deleteUnit (DELETE /{id}) ## Back-end server (edifikana/back-end) ### Domain / persistence layer - Add `Unit` service model (mirrors DB schema, includes `createdAt`) - Add `UnitEntity` + `CreateUnitEntity` Supabase entity with soft-delete support - Add `UnitDatastore` interface (createUnit, getUnit, getUnits, updateUnit, deleteUnit) - Add `SupabaseUnitDatastore` implementation: - Queries filter `deleted_at IS NULL` (soft-delete pattern) - updateUnit uses partial PATCH via `UnitEntity.CreateUnitEntity` - deleteUnit sets `deleted_at = clock.now()` instead of hard-deleting - Add mapper functions in `SupabaseMappers`: `CreateUnitEntity(...)` factory and `UnitEntity.toUnit()` extension ### Service layer - Add `UnitService` — thin delegation to `UnitDatastore` with `.getOrThrow()` / `.getOrNull()` unwrapping ### Controller layer - Add `UnitController` with RBAC gates per endpoint: - createUnit → MANAGER or higher (scoped to org) - getUnit → EMPLOYEE or higher (scoped to unit → org lookup) - getUnits → EMPLOYEE or higher (scoped to org) - updateUnit → MANAGER or higher (scoped to unit → org lookup) - deleteUnit → ADMIN or higher (scoped to unit → org lookup) - Add `Unit.toUnitNetworkResponse()` mapper in `NetworkMappers` ### Authorization - Extend `RBACService` with `UnitId`-scoped `hasRole` / `hasRoleOrHigher` overloads; looks up the unit's `orgId` via `UnitDatastore` and delegates to the existing org-level role check (same pattern as DocumentId / PropertyId) - Add `unitDatastore: UnitDatastore` constructor parameter to `RBACService` ### Dependency injection - Register `SupabaseUnitDatastore` → `UnitDatastore` in `DatastoreModule` - Register `UnitService` in `ServicesModule` - Register `UnitController` as `Controller` in `ControllerModule` ## Tests - Add `UnitControllerTest` — 10 tests covering success and auth-failure paths for all five CRUD operations using MockK + Koin test harness - Add JSON fixture files for all request/response bodies - Update `TestModule`: add `UnitController` to `TestControllerModule` and mock `UnitService` in `TestServiceModule` - Fix `RBACServiceTest`: pass new `unitDatastore` mock to `RBACService` constructor Co-Authored-By: Claude Sonnet 4.6 --- .../com/cramsan/edifikana/api/UnitApi.kt | 57 +++ .../server/controller/NetworkMappers.kt | 20 + .../server/controller/UnitController.kt | 170 ++++++++ .../server/datastore/UnitDatastore.kt | 57 +++ .../datastore/supabase/SupabaseMappers.kt | 47 ++ .../supabase/SupabaseUnitDatastore.kt | 137 ++++++ .../datastore/supabase/models/UnitEntity.kt | 58 +++ .../dependencyinjection/ControllerModule.kt | 2 + .../dependencyinjection/DatastoreModule.kt | 5 + .../dependencyinjection/ServicesModule.kt | 2 + .../edifikana/server/service/UnitService.kt | 100 +++++ .../service/authorization/RBACService.kt | 53 +++ .../edifikana/server/service/models/Unit.kt | 24 + .../server/controller/UnitControllerTest.kt | 411 ++++++++++++++++++ .../server/dependencyinjection/TestModule.kt | 4 + .../service/authorization/RBACServiceTest.kt | 4 + .../requests/create_unit_request.json | 10 + .../requests/create_unit_response.json | 10 + .../resources/requests/get_unit_response.json | 10 + .../requests/get_units_response.json | 25 ++ .../requests/update_unit_request.json | 8 + .../requests/update_unit_response.json | 11 + .../cramsan/edifikana/lib/model/UnitModel.kt | 16 + .../lib/model/network/GetUnitsQueryParams.kt | 18 + 24 files changed, 1259 insertions(+) create mode 100644 edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UnitApi.kt create mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt create mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt create mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt create mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UnitEntity.kt create mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UnitService.kt create mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/Unit.kt create mode 100644 edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt create mode 100644 edifikana/back-end/src/test/resources/requests/create_unit_request.json create mode 100644 edifikana/back-end/src/test/resources/requests/create_unit_response.json create mode 100644 edifikana/back-end/src/test/resources/requests/get_unit_response.json create mode 100644 edifikana/back-end/src/test/resources/requests/get_units_response.json create mode 100644 edifikana/back-end/src/test/resources/requests/update_unit_request.json create mode 100644 edifikana/back-end/src/test/resources/requests/update_unit_response.json create mode 100644 edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/UnitModel.kt create mode 100644 edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/GetUnitsQueryParams.kt diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UnitApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UnitApi.kt new file mode 100644 index 000000000..452f9280a --- /dev/null +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/UnitApi.kt @@ -0,0 +1,57 @@ +package com.cramsan.edifikana.api + +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.lib.model.network.CreateUnitNetworkRequest +import com.cramsan.edifikana.lib.model.network.GetUnitsQueryParams +import com.cramsan.edifikana.lib.model.network.UnitListNetworkResponse +import com.cramsan.edifikana.lib.model.network.UnitNetworkResponse +import com.cramsan.edifikana.lib.model.network.UpdateUnitNetworkRequest +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.NoPathParam +import com.cramsan.framework.annotations.api.NoQueryParam +import com.cramsan.framework.annotations.api.NoRequestBody +import com.cramsan.framework.annotations.api.NoResponseBody +import com.cramsan.framework.networkapi.Api +import io.ktor.http.HttpMethod + +/** + * API definition for unit CRUD operations. + */ +@OptIn(NetworkModel::class) +object UnitApi : Api("unit") { + + val createUnit = operation< + CreateUnitNetworkRequest, + NoQueryParam, + NoPathParam, + UnitNetworkResponse + >(HttpMethod.Post) + + val getUnit = operation< + NoRequestBody, + NoQueryParam, + UnitId, + UnitNetworkResponse + >(HttpMethod.Get) + + val getUnits = operation< + NoRequestBody, + GetUnitsQueryParams, + NoPathParam, + UnitListNetworkResponse + >(HttpMethod.Get) + + val updateUnit = operation< + UpdateUnitNetworkRequest, + NoQueryParam, + UnitId, + UnitNetworkResponse + >(HttpMethod.Put) + + val deleteUnit = operation< + NoRequestBody, + NoQueryParam, + UnitId, + NoResponseBody + >(HttpMethod.Delete) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt index 83a3c9165..a52c152fd 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt @@ -13,6 +13,7 @@ import com.cramsan.edifikana.lib.model.network.NotificationNetworkResponse import com.cramsan.edifikana.lib.model.network.OrganizationNetworkResponse import com.cramsan.edifikana.lib.model.network.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.TimeCardEventNetworkResponse +import com.cramsan.edifikana.lib.model.network.UnitNetworkResponse import com.cramsan.edifikana.lib.model.network.UserNetworkResponse import com.cramsan.edifikana.server.service.models.Asset import com.cramsan.edifikana.server.service.models.Document @@ -24,6 +25,7 @@ import com.cramsan.edifikana.server.service.models.Organization import com.cramsan.edifikana.server.service.models.OrgMemberView import com.cramsan.edifikana.server.service.models.Property import com.cramsan.edifikana.server.service.models.TimeCardEvent +import com.cramsan.edifikana.server.service.models.Unit import com.cramsan.edifikana.server.service.models.User import com.cramsan.framework.annotations.NetworkModel import kotlin.time.ExperimentalTime @@ -199,3 +201,21 @@ fun Document.toDocumentNetworkResponse(): DocumentNetworkResponse { createdAt = createdAt, ) } + +/** + * Converts a [Unit] domain model to a [UnitNetworkResponse] network model. + */ +@OptIn(NetworkModel::class) +fun Unit.toUnitNetworkResponse(): UnitNetworkResponse { + return UnitNetworkResponse( + unitId = id, + propertyId = propertyId, + orgId = orgId, + unitNumber = unitNumber, + bedrooms = bedrooms, + bathrooms = bathrooms, + sqFt = sqFt, + floor = floor, + notes = notes, + ) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt new file mode 100644 index 000000000..3aa2e9787 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt @@ -0,0 +1,170 @@ +package com.cramsan.edifikana.server.controller + +import com.cramsan.edifikana.api.UnitApi +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.lib.model.network.CreateUnitNetworkRequest +import com.cramsan.edifikana.lib.model.network.GetUnitsQueryParams +import com.cramsan.edifikana.lib.model.network.UnitListNetworkResponse +import com.cramsan.edifikana.lib.model.network.UnitNetworkResponse +import com.cramsan.edifikana.lib.model.network.UpdateUnitNetworkRequest +import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload +import com.cramsan.edifikana.server.service.UnitService +import com.cramsan.edifikana.server.service.authorization.RBACService +import com.cramsan.edifikana.server.service.models.UserRole +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.NoPathParam +import com.cramsan.framework.annotations.api.NoQueryParam +import com.cramsan.framework.annotations.api.NoRequestBody +import com.cramsan.framework.annotations.api.NoResponseBody +import com.cramsan.framework.core.ktor.Controller +import com.cramsan.framework.core.ktor.OperationHandler.register +import com.cramsan.framework.core.ktor.OperationRequest +import com.cramsan.framework.core.ktor.auth.ClientContext +import com.cramsan.framework.core.ktor.auth.ContextRetriever +import com.cramsan.framework.core.ktor.handler +import com.cramsan.framework.utils.exceptions.ClientRequestExceptions.UnauthorizedException +import io.ktor.server.routing.Routing + +/** + * Controller for unit CRUD operations. + */ +@OptIn(NetworkModel::class) +class UnitController( + private val unitService: UnitService, + private val rbacService: RBACService, + private val contextRetriever: ContextRetriever, +) : Controller { + + private val unauthorizedMsg = "You are not authorized to perform this action in your organization." + + /** + * Creates a new unit. Requires MANAGER role or higher in the target org. + */ + suspend fun createUnit( + request: OperationRequest< + CreateUnitNetworkRequest, + NoQueryParam, + NoPathParam, + ClientContext.AuthenticatedClientContext + > + ): UnitNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.requestBody.orgId, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + return unitService.createUnit( + propertyId = request.requestBody.propertyId, + orgId = request.requestBody.orgId, + unitNumber = request.requestBody.unitNumber, + bedrooms = request.requestBody.bedrooms, + bathrooms = request.requestBody.bathrooms, + sqFt = request.requestBody.sqFt, + floor = request.requestBody.floor, + notes = request.requestBody.notes, + ).toUnitNetworkResponse() + } + + /** + * Retrieves a single unit by its [UnitId]. Requires EMPLOYEE role or higher. + */ + suspend fun getUnit( + request: OperationRequest< + NoRequestBody, + NoQueryParam, + UnitId, + ClientContext.AuthenticatedClientContext + > + ): UnitNetworkResponse? { + val unitId = request.pathParam + if (!rbacService.hasRoleOrHigher(request.context, unitId, UserRole.EMPLOYEE)) { + throw UnauthorizedException(unauthorizedMsg) + } + return unitService.getUnit(unitId)?.toUnitNetworkResponse() + } + + /** + * Lists all units for the org in the query params. Requires EMPLOYEE role or higher. + */ + suspend fun getUnits( + request: OperationRequest< + NoRequestBody, + GetUnitsQueryParams, + NoPathParam, + ClientContext.AuthenticatedClientContext + > + ): UnitListNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.queryParam.orgId, UserRole.EMPLOYEE)) { + throw UnauthorizedException(unauthorizedMsg) + } + val units = unitService.getUnits( + orgId = request.queryParam.orgId, + propertyId = request.queryParam.propertyId, + ).map { it.toUnitNetworkResponse() } + return UnitListNetworkResponse(units) + } + + /** + * Updates a unit's fields. Requires MANAGER role or higher. + */ + suspend fun updateUnit( + request: OperationRequest< + UpdateUnitNetworkRequest, + NoQueryParam, + UnitId, + ClientContext.AuthenticatedClientContext + > + ): UnitNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + return unitService.updateUnit( + unitId = request.pathParam, + unitNumber = request.requestBody.unitNumber, + bedrooms = request.requestBody.bedrooms, + bathrooms = request.requestBody.bathrooms, + sqFt = request.requestBody.sqFt, + floor = request.requestBody.floor, + notes = request.requestBody.notes, + ).toUnitNetworkResponse() + } + + /** + * Soft-deletes a unit. Requires ADMIN role or higher. + */ + suspend fun deleteUnit( + request: OperationRequest< + NoRequestBody, + NoQueryParam, + UnitId, + ClientContext.AuthenticatedClientContext + > + ): NoResponseBody { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.ADMIN)) { + throw UnauthorizedException(unauthorizedMsg) + } + unitService.deleteUnit(request.pathParam) + return NoResponseBody + } + + /** + * Registers all unit routes. + */ + override fun registerRoutes(route: Routing) { + UnitApi.register(route) { + handler(api.createUnit, contextRetriever) { request -> + createUnit(request) + } + handler(api.getUnit, contextRetriever) { request -> + getUnit(request) + } + handler(api.getUnits, contextRetriever) { request -> + getUnits(request) + } + handler(api.updateUnit, contextRetriever) { request -> + updateUnit(request) + } + handler(api.deleteUnit, contextRetriever) { request -> + deleteUnit(request) + } + } + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt new file mode 100644 index 000000000..b06737a29 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt @@ -0,0 +1,57 @@ +package com.cramsan.edifikana.server.datastore + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.server.service.models.Unit + +/** + * Interface for the unit datastore. + */ +interface UnitDatastore { + + /** + * Creates a new unit record. Returns the [Result] of the operation with the created [Unit]. + */ + suspend fun createUnit( + propertyId: PropertyId, + orgId: OrganizationId, + unitNumber: String, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, + ): Result + + /** + * Retrieves a unit by [unitId]. Returns [Result] with the [Unit] if found. + */ + suspend fun getUnit(unitId: UnitId): Result + + /** + * Retrieves all non-deleted units for [orgId], optionally filtered by [propertyId]. + */ + suspend fun getUnits( + orgId: OrganizationId, + propertyId: PropertyId?, + ): Result> + + /** + * Updates the fields of an existing unit. Returns the updated [Unit]. + */ + suspend fun updateUnit( + unitId: UnitId, + unitNumber: String?, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, + ): Result + + /** + * Soft-deletes the unit with the given [unitId]. Returns true if the record was deleted. + */ + suspend fun deleteUnit(unitId: UnitId): Result +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt index e1774bbc7..9d9efd293 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt @@ -30,6 +30,7 @@ import com.cramsan.edifikana.server.datastore.supabase.models.OrgMemberViewEntit import com.cramsan.edifikana.server.datastore.supabase.models.OrganizationEntity import com.cramsan.edifikana.server.datastore.supabase.models.PropertyEntity import com.cramsan.edifikana.server.datastore.supabase.models.TimeCardEventEntity +import com.cramsan.edifikana.server.datastore.supabase.models.UnitEntity import com.cramsan.edifikana.server.datastore.supabase.models.UserEntity import com.cramsan.edifikana.server.service.models.Document import com.cramsan.edifikana.server.service.models.Employee @@ -40,6 +41,7 @@ import com.cramsan.edifikana.server.service.models.OrgMemberView import com.cramsan.edifikana.server.service.models.Organization import com.cramsan.edifikana.server.service.models.Property import com.cramsan.edifikana.server.service.models.TimeCardEvent +import com.cramsan.edifikana.server.service.models.Unit import com.cramsan.edifikana.server.service.models.User import com.cramsan.edifikana.server.service.models.UserRole import com.cramsan.framework.annotations.SupabaseModel @@ -434,3 +436,48 @@ fun OrgMemberViewEntity.toOrgMemberView(): OrgMemberView { lastName = this.lastName, ) } + +/** + * Creates a [UnitEntity.CreateUnitEntity] from the provided parameters. + */ +@OptIn(SupabaseModel::class) +fun CreateUnitEntity( + propertyId: PropertyId, + orgId: OrganizationId, + unitNumber: String, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, +): UnitEntity.CreateUnitEntity { + return UnitEntity.CreateUnitEntity( + propertyId = propertyId, + orgId = orgId, + unitNumber = unitNumber, + bedrooms = bedrooms, + bathrooms = bathrooms, + sqFt = sqFt, + floor = floor, + notes = notes, + ) +} + +/** + * Maps a [UnitEntity] to the [Unit] service model. + */ +@OptIn(SupabaseModel::class) +fun UnitEntity.toUnit(): Unit { + return Unit( + id = UnitId(this.unitId), + propertyId = this.propertyId, + orgId = this.orgId, + unitNumber = this.unitNumber, + bedrooms = this.bedrooms, + bathrooms = this.bathrooms, + sqFt = this.sqFt, + floor = this.floor, + notes = this.notes, + createdAt = this.createdAt, + ) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt new file mode 100644 index 000000000..3b5f21603 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt @@ -0,0 +1,137 @@ +package com.cramsan.edifikana.server.datastore.supabase + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.server.datastore.UnitDatastore +import com.cramsan.edifikana.server.datastore.supabase.models.UnitEntity +import com.cramsan.edifikana.server.service.models.Unit +import com.cramsan.framework.annotations.SupabaseModel +import com.cramsan.framework.core.runSuspendCatching +import com.cramsan.framework.logging.logD +import io.github.jan.supabase.postgrest.Postgrest +import kotlin.time.Clock + +/** + * Datastore for managing unit records using Supabase. + */ +class SupabaseUnitDatastore( + private val postgrest: Postgrest, + private val clock: Clock, +) : UnitDatastore { + + /** + * Inserts a new unit row and returns the created [Unit]. + */ + @OptIn(SupabaseModel::class) + override suspend fun createUnit( + propertyId: PropertyId, + orgId: OrganizationId, + unitNumber: String, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, + ): Result = runSuspendCatching(TAG) { + logD(TAG, "Creating unit: %s", unitNumber) + val requestEntity = CreateUnitEntity( + propertyId = propertyId, + orgId = orgId, + unitNumber = unitNumber, + bedrooms = bedrooms, + bathrooms = bathrooms, + sqFt = sqFt, + floor = floor, + notes = notes, + ) + val created = postgrest.from(UnitEntity.COLLECTION).insert(requestEntity) { + select() + }.decodeSingle() + logD(TAG, "Unit created: %s", created.unitId) + created.toUnit() + } + + /** + * Retrieves a single unit by [unitId]. Returns null if not found or soft-deleted. + */ + @OptIn(SupabaseModel::class) + override suspend fun getUnit(unitId: UnitId): Result = runSuspendCatching(TAG) { + logD(TAG, "Getting unit: %s", unitId) + postgrest.from(UnitEntity.COLLECTION).select { + filter { + UnitEntity::unitId eq unitId.unitId + UnitEntity::deletedAt isExact null + } + }.decodeSingleOrNull()?.toUnit() + } + + /** + * Lists all non-deleted units for [orgId], with an optional [propertyId] filter. + */ + @OptIn(SupabaseModel::class) + override suspend fun getUnits( + orgId: OrganizationId, + propertyId: PropertyId?, + ): Result> = runSuspendCatching(TAG) { + logD(TAG, "Getting units for org: %s", orgId) + postgrest.from(UnitEntity.COLLECTION).select { + filter { + UnitEntity::orgId eq orgId.id + UnitEntity::deletedAt isExact null + propertyId?.let { UnitEntity::propertyId eq it.propertyId } + } + }.decodeList().map { it.toUnit() } + } + + /** + * Updates the fields of an existing unit. Returns the updated [Unit]. + */ + @OptIn(SupabaseModel::class) + override suspend fun updateUnit( + unitId: UnitId, + unitNumber: String?, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, + ): Result = runSuspendCatching(TAG) { + logD(TAG, "Updating unit: %s", unitId) + postgrest.from(UnitEntity.COLLECTION).update({ + unitNumber?.let { value -> UnitEntity::unitNumber setTo value } + bedrooms?.let { value -> UnitEntity::bedrooms setTo value } + bathrooms?.let { value -> UnitEntity::bathrooms setTo value } + sqFt?.let { value -> UnitEntity::sqFt setTo value } + floor?.let { value -> UnitEntity::floor setTo value } + notes?.let { value -> UnitEntity::notes setTo value } + }) { + select() + filter { + UnitEntity::unitId eq unitId.unitId + UnitEntity::deletedAt isExact null + } + }.decodeSingle().toUnit() + } + + /** + * Soft-deletes a unit by setting [UnitEntity.deletedAt]. Returns true if the record was found and updated. + */ + @OptIn(SupabaseModel::class) + override suspend fun deleteUnit(unitId: UnitId): Result = runSuspendCatching(TAG) { + logD(TAG, "Soft deleting unit: %s", unitId) + postgrest.from(UnitEntity.COLLECTION).update({ + UnitEntity::deletedAt setTo clock.now() + }) { + select() + filter { + UnitEntity::unitId eq unitId.unitId + UnitEntity::deletedAt isExact null + } + }.decodeSingleOrNull() != null + } + + companion object { + const val TAG = "SupabaseUnitDatastore" + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UnitEntity.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UnitEntity.kt new file mode 100644 index 000000000..79b814f48 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UnitEntity.kt @@ -0,0 +1,58 @@ +package com.cramsan.edifikana.server.datastore.supabase.models + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.framework.annotations.SupabaseModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * Entity representing a property unit stored in the database. + */ +@Serializable +@SupabaseModel +data class UnitEntity( + @SerialName("unit_id") + val unitId: String, + @SerialName("property_id") + val propertyId: PropertyId, + @SerialName("org_id") + val orgId: OrganizationId, + @SerialName("unit_number") + val unitNumber: String, + val bedrooms: Int? = null, + val bathrooms: Int? = null, + @SerialName("sq_ft") + val sqFt: Int? = null, + val floor: Int? = null, + val notes: String? = null, + @SerialName("created_at") + val createdAt: Instant, + @SerialName("deleted_at") + val deletedAt: Instant? = null, +) { + companion object { + const val COLLECTION = "units" + } + + /** + * Entity representing a new unit to be inserted. + */ + @Serializable + @SupabaseModel + data class CreateUnitEntity( + @SerialName("property_id") + val propertyId: PropertyId, + @SerialName("org_id") + val orgId: OrganizationId, + @SerialName("unit_number") + val unitNumber: String, + val bedrooms: Int? = null, + val bathrooms: Int? = null, + @SerialName("sq_ft") + val sqFt: Int? = null, + val floor: Int? = null, + val notes: String? = null, + ) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt index 8588d102c..6fb57ef1c 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt @@ -10,6 +10,7 @@ import com.cramsan.edifikana.server.controller.OrganizationController import com.cramsan.edifikana.server.controller.PropertyController import com.cramsan.edifikana.server.controller.StorageController import com.cramsan.edifikana.server.controller.TimeCardController +import com.cramsan.edifikana.server.controller.UnitController import com.cramsan.edifikana.server.controller.UserController import com.cramsan.framework.core.ktor.Controller import org.koin.core.module.dsl.bind @@ -32,4 +33,5 @@ internal val ControllerModule = module { singleOf(::OrganizationController) { bind() } singleOf(::NotificationController) { bind() } singleOf(::DocumentController) { bind() } + singleOf(::UnitController) { bind() } } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt index 3355a1525..ae10ff013 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt @@ -10,6 +10,7 @@ import com.cramsan.edifikana.server.datastore.OrganizationDatastore import com.cramsan.edifikana.server.datastore.PropertyDatastore import com.cramsan.edifikana.server.datastore.StorageDatastore import com.cramsan.edifikana.server.datastore.TimeCardDatastore +import com.cramsan.edifikana.server.datastore.UnitDatastore import com.cramsan.edifikana.server.datastore.UserDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseDocumentDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseEmployeeDatastore @@ -20,6 +21,7 @@ import com.cramsan.edifikana.server.datastore.supabase.SupabaseOrganizationDatas import com.cramsan.edifikana.server.datastore.supabase.SupabasePropertyDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseStorageDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseTimeCardDatastore +import com.cramsan.edifikana.server.datastore.supabase.SupabaseUnitDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseUserDatastore import com.cramsan.edifikana.server.settings.EdifikanaSettingKey import io.github.jan.supabase.SupabaseClient @@ -116,4 +118,7 @@ val DatastoreModule = module { singleOf(::SupabaseMembershipDatastore) { bind() } + singleOf(::SupabaseUnitDatastore) { + bind() + } } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt index 21345eeaa..3cf198388 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt @@ -9,6 +9,7 @@ import com.cramsan.edifikana.server.service.OrganizationService import com.cramsan.edifikana.server.service.PropertyService import com.cramsan.edifikana.server.service.StorageService import com.cramsan.edifikana.server.service.TimeCardService +import com.cramsan.edifikana.server.service.UnitService import com.cramsan.edifikana.server.service.UserService import com.cramsan.edifikana.server.service.authorization.RBACService import org.koin.core.module.dsl.singleOf @@ -29,4 +30,5 @@ internal val ServicesModule = module { singleOf(::OrganizationService) singleOf(::RBACService) singleOf(::DocumentService) + singleOf(::UnitService) } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UnitService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UnitService.kt new file mode 100644 index 000000000..cd422737e --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/UnitService.kt @@ -0,0 +1,100 @@ +package com.cramsan.edifikana.server.service + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.server.datastore.UnitDatastore +import com.cramsan.edifikana.server.service.models.Unit +import com.cramsan.framework.logging.logD + +/** + * Service for managing property units. Delegates persistence to [UnitDatastore]. + */ +class UnitService( + private val unitDatastore: UnitDatastore, +) { + + /** + * Creates a new unit record. + */ + suspend fun createUnit( + propertyId: PropertyId, + orgId: OrganizationId, + unitNumber: String, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, + ): Unit { + logD(TAG, "createUnit") + return unitDatastore.createUnit( + propertyId = propertyId, + orgId = orgId, + unitNumber = unitNumber, + bedrooms = bedrooms, + bathrooms = bathrooms, + sqFt = sqFt, + floor = floor, + notes = notes, + ).getOrThrow() + } + + /** + * Retrieves a single unit by [unitId]. Returns null if not found. + */ + suspend fun getUnit(unitId: UnitId): Unit? { + logD(TAG, "getUnit") + return unitDatastore.getUnit(unitId).getOrNull() + } + + /** + * Lists all units for [orgId], optionally filtered by [propertyId]. + */ + suspend fun getUnits( + orgId: OrganizationId, + propertyId: PropertyId?, + ): List { + logD(TAG, "getUnits") + return unitDatastore.getUnits( + orgId = orgId, + propertyId = propertyId, + ).getOrThrow() + } + + /** + * Updates the fields of an existing unit. Returns the updated [Unit]. + */ + suspend fun updateUnit( + unitId: UnitId, + unitNumber: String?, + bedrooms: Int?, + bathrooms: Int?, + sqFt: Int?, + floor: Int?, + notes: String?, + ): Unit { + logD(TAG, "updateUnit") + return unitDatastore.updateUnit( + unitId = unitId, + unitNumber = unitNumber, + bedrooms = bedrooms, + bathrooms = bathrooms, + sqFt = sqFt, + floor = floor, + notes = notes, + ).getOrThrow() + } + + /** + * Soft-deletes a unit record. Returns true if the record was successfully deleted. + */ + suspend fun deleteUnit(unitId: UnitId): Boolean { + logD(TAG, "deleteUnit") + return unitDatastore.deleteUnit(unitId).getOrThrow() + } + + companion object { + private const val TAG = "UnitService" + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt index 02b29a1e1..f0e182515 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt @@ -6,6 +6,7 @@ import com.cramsan.edifikana.lib.model.EventLogEntryId import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.PropertyId import com.cramsan.edifikana.lib.model.TimeCardEventId +import com.cramsan.edifikana.lib.model.UnitId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.datastore.DocumentDatastore @@ -14,6 +15,7 @@ import com.cramsan.edifikana.server.datastore.EventLogDatastore import com.cramsan.edifikana.server.datastore.OrganizationDatastore import com.cramsan.edifikana.server.datastore.PropertyDatastore import com.cramsan.edifikana.server.datastore.TimeCardDatastore +import com.cramsan.edifikana.server.datastore.UnitDatastore import com.cramsan.edifikana.server.datastore.supabase.toUserRole import com.cramsan.edifikana.server.service.models.UserRole import com.cramsan.framework.core.ktor.auth.ClientContext @@ -31,6 +33,7 @@ class RBACService( private val timeCardDatastore: TimeCardDatastore, private val eventLogDatastore: EventLogDatastore, private val documentDatastore: DocumentDatastore, + private val unitDatastore: UnitDatastore, ) { private val propertyNotFoundException = "ERROR: PROPERTY NOT FOUND!" @@ -260,6 +263,56 @@ class RBACService( return getUserRoleForOrganizationAction(context, document.orgId) } + /** + * Checks if the user has the required role to perform actions on the target unit. + * + * @param context The authenticated client context containing user information. + * @param targetUnitId The ID of the target unit on which the action is to be performed. + * @param requiredRole The role required to perform the action. + * @return True if the user has the required role, false otherwise. + */ + suspend fun hasRole( + context: ClientContext.AuthenticatedClientContext, + targetUnitId: UnitId, + requiredRole: UserRole, + ): Boolean { + val userRole = getUserRoleForUnitAction(context, targetUnitId) + return userRole == requiredRole + } + + /** + * Checks if the user has the required role or higher to perform actions on the target unit. + * + * @param context The authenticated client context containing user information. + * @param targetUnitId The ID of the target unit on which the action is to be performed. + * @param requiredRole The minimum role required to perform the action. + * @return True if the user has the required role or higher, false otherwise. + */ + suspend fun hasRoleOrHigher( + context: ClientContext.AuthenticatedClientContext, + targetUnitId: UnitId, + requiredRole: UserRole, + ): Boolean { + val userRole = getUserRoleForUnitAction(context, targetUnitId) + return userRole.level <= requiredRole.level + } + + /** + * Retrieves the user role for the action being performed on the target unit. + * + * @param context The authenticated client context containing user information. + * @param targetUnitId The ID of the target unit on which the action is to be performed. + * @return The user role if the action is allowed, or UNAUTHORIZED if not. + */ + private suspend fun getUserRoleForUnitAction( + context: ClientContext.AuthenticatedClientContext, + targetUnitId: UnitId, + ): UserRole { + val unit = unitDatastore.getUnit(targetUnitId).getOrThrow() + ?: return UserRole.UNAUTHORIZED + return getUserRoleForOrganizationAction(context, unit.orgId) + } + /** * Checks if the user has the required role to perform actions on the target employee. * diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/Unit.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/Unit.kt new file mode 100644 index 000000000..1489bd73e --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/Unit.kt @@ -0,0 +1,24 @@ +package com.cramsan.edifikana.server.service.models + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Domain model representing a property unit. + */ +@OptIn(ExperimentalTime::class) +data class Unit( + val id: UnitId, + val propertyId: PropertyId, + val orgId: OrganizationId, + val unitNumber: String, + val bedrooms: Int?, + val bathrooms: Int?, + val sqFt: Int?, + val floor: Int?, + val notes: String?, + val createdAt: Instant, +) diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt new file mode 100644 index 000000000..40f336f8b --- /dev/null +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt @@ -0,0 +1,411 @@ +@file:OptIn(ExperimentalTime::class) + +package com.cramsan.edifikana.server.controller + +import com.cramsan.architecture.server.test.startTestKoin +import com.cramsan.architecture.server.test.testBackEndApplication +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.lib.model.UserId +import com.cramsan.edifikana.lib.serialization.createJson +import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload +import com.cramsan.edifikana.server.dependencyinjection.TestControllerModule +import com.cramsan.edifikana.server.dependencyinjection.TestServiceModule +import com.cramsan.edifikana.server.dependencyinjection.testApplicationModule +import com.cramsan.edifikana.server.service.UnitService +import com.cramsan.edifikana.server.service.authorization.RBACService +import com.cramsan.edifikana.server.service.models.Unit +import com.cramsan.edifikana.server.service.models.UserRole +import com.cramsan.edifikana.server.utils.readFileContent +import com.cramsan.framework.core.ktor.auth.ClientContext +import com.cramsan.framework.core.ktor.auth.ContextRetriever +import com.cramsan.framework.test.CoroutineTest +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.get +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class UnitControllerTest : CoroutineTest(), KoinTest { + + @BeforeTest + fun setupTest() { + startTestKoin( + testApplicationModule(createJson()), + TestControllerModule, + TestServiceModule, + ) + } + + @AfterTest + fun cleanUp() { + stopKoin() + } + + @Test + fun `test createUnit succeeds when user has required role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/create_unit_request.json") + val expectedResponse = readFileContent("requests/create_unit_response.json") + val unitService = get() + val rbacService = get() + coEvery { + unitService.createUnit( + propertyId = PropertyId("property123"), + orgId = OrganizationId("org123"), + unitNumber = "101A", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = null, + ) + }.answers { + Unit( + id = UnitId("unit123"), + propertyId = PropertyId("property123"), + orgId = OrganizationId("org123"), + unitNumber = "101A", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = null, + createdAt = Instant.fromEpochSeconds(0), + ) + } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, OrganizationId("org123"), UserRole.MANAGER) + }.answers { true } + + // Act + val response = client.post("unit") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test createUnit fails when user lacks required role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/create_unit_request.json") + val unitService = get() + val rbacService = get() + val contextRetriever = get>() + val expectedResponse = "You are not authorized to perform this action in your organization." + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, OrganizationId("org123"), UserRole.MANAGER) + }.answers { false } + + // Act + val response = client.post("unit") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + coVerify { unitService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getUnit succeeds when user has required role`() = testBackEndApplication { + // Arrange + val expectedResponse = readFileContent("requests/get_unit_response.json") + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + coEvery { unitService.getUnit(unitId) }.answers { + Unit( + id = UnitId("unit123"), + propertyId = PropertyId("property123"), + orgId = OrganizationId("org123"), + unitNumber = "101A", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = null, + createdAt = Instant.fromEpochSeconds(0), + ) + } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.EMPLOYEE) + }.answers { true } + + // Act + val response = client.get("unit/unit123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getUnit fails when user lacks required role`() = testBackEndApplication { + // Arrange + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + val contextRetriever = get>() + val expectedResponse = "You are not authorized to perform this action in your organization." + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.EMPLOYEE) + }.answers { false } + + // Act + val response = client.get("unit/unit123") + + // Assert + coVerify { unitService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getUnits succeeds when user has required role`() = testBackEndApplication { + // Arrange + val expectedResponse = readFileContent("requests/get_units_response.json") + val unitService = get() + val rbacService = get() + coEvery { + unitService.getUnits(OrganizationId("org123"), PropertyId("property123")) + }.answers { + listOf( + Unit( + id = UnitId("unit123"), + propertyId = PropertyId("property123"), + orgId = OrganizationId("org123"), + unitNumber = "101A", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = null, + createdAt = Instant.fromEpochSeconds(0), + ), + Unit( + id = UnitId("unit456"), + propertyId = PropertyId("property123"), + orgId = OrganizationId("org123"), + unitNumber = "202B", + bedrooms = 3, + bathrooms = 2, + sqFt = 1100, + floor = 2, + notes = "Corner unit", + createdAt = Instant.fromEpochSeconds(0), + ), + ) + } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, OrganizationId("org123"), UserRole.EMPLOYEE) + }.answers { true } + + // Act + val response = client.get("unit?org_id=org123&property_id=property123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getUnits fails when user lacks required role`() = testBackEndApplication { + // Arrange + val unitService = get() + val rbacService = get() + val expectedResponse = "You are not authorized to perform this action in your organization." + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, OrganizationId("org123"), UserRole.EMPLOYEE) + }.answers { false } + + // Act + val response = client.get("unit?org_id=org123&property_id=property123") + + // Assert + coVerify { unitService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test updateUnit succeeds when user has required role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/update_unit_request.json") + val expectedResponse = readFileContent("requests/update_unit_response.json") + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + coEvery { + unitService.updateUnit( + unitId = unitId, + unitNumber = "101B", + bedrooms = 3, + bathrooms = 2, + sqFt = 900, + floor = 1, + notes = "Renovated", + ) + }.answers { + Unit( + id = UnitId("unit123"), + propertyId = PropertyId("property123"), + orgId = OrganizationId("org123"), + unitNumber = "101B", + bedrooms = 3, + bathrooms = 2, + sqFt = 900, + floor = 1, + notes = "Renovated", + createdAt = Instant.fromEpochSeconds(0), + ) + } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.MANAGER) + }.answers { true } + + // Act + val response = client.put("unit/unit123") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test updateUnit fails when user lacks required role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/update_unit_request.json") + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + val expectedResponse = "You are not authorized to perform this action in your organization." + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.MANAGER) + }.answers { false } + + // Act + val response = client.put("unit/unit123") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + coVerify { unitService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test deleteUnit succeeds when user has required role`() = testBackEndApplication { + // Arrange + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + coEvery { unitService.deleteUnit(unitId) }.answers { true } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.ADMIN) + }.answers { true } + + // Act + val response = client.delete("unit/unit123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `test deleteUnit fails when user lacks required role`() = testBackEndApplication { + // Arrange + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + val expectedResponse = "You are not authorized to perform this action in your organization." + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.ADMIN) + }.answers { false } + + // Act + val response = client.delete("unit/unit123") + + // Assert + coVerify { unitService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } +} diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt index 12dd7819c..602370cec 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt @@ -9,6 +9,7 @@ import com.cramsan.edifikana.server.controller.OrganizationController import com.cramsan.edifikana.server.controller.PropertyController import com.cramsan.edifikana.server.controller.StorageController import com.cramsan.edifikana.server.controller.TimeCardController +import com.cramsan.edifikana.server.controller.UnitController import com.cramsan.edifikana.server.controller.UserController import com.cramsan.edifikana.server.service.EmployeeService import com.cramsan.edifikana.server.service.EventLogService @@ -18,6 +19,7 @@ import com.cramsan.edifikana.server.service.OrganizationService import com.cramsan.edifikana.server.service.PropertyService import com.cramsan.edifikana.server.service.StorageService import com.cramsan.edifikana.server.service.TimeCardService +import com.cramsan.edifikana.server.service.UnitService import com.cramsan.edifikana.server.service.UserService import com.cramsan.edifikana.server.service.authorization.RBACService import com.cramsan.framework.core.ktor.Controller @@ -42,6 +44,7 @@ internal val TestControllerModule = module { singleOf(::OrganizationController) { bind() } singleOf(::NotificationController) { bind() } singleOf(::MembershipController) { bind() } + singleOf(::UnitController) { bind() } } /** @@ -58,6 +61,7 @@ internal val TestServiceModule = module { single { mockk() } single { mockk() } single { mockk() } + single { mockk() } } internal fun testApplicationModule(json: Json) = module { diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt index 4fdbb4263..6465342e6 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt @@ -8,6 +8,7 @@ import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.datastore.DocumentDatastore import com.cramsan.edifikana.server.datastore.EmployeeDatastore +import com.cramsan.edifikana.server.datastore.UnitDatastore import com.cramsan.edifikana.server.datastore.EventLogDatastore import com.cramsan.edifikana.server.datastore.OrganizationDatastore import com.cramsan.edifikana.server.datastore.PropertyDatastore @@ -44,6 +45,7 @@ class RBACServiceTest { private lateinit var timeCardDatastore: TimeCardDatastore private lateinit var eventLogDatastore: EventLogDatastore private lateinit var documentDatastore: DocumentDatastore + private lateinit var unitDatastore: UnitDatastore private lateinit var rbac: RBACService @BeforeEach @@ -55,6 +57,7 @@ class RBACServiceTest { timeCardDatastore = mockk() eventLogDatastore = mockk() documentDatastore = mockk() + unitDatastore = mockk() rbac = RBACService( propertyDatastore, orgDatastore, @@ -62,6 +65,7 @@ class RBACServiceTest { timeCardDatastore, eventLogDatastore, documentDatastore, + unitDatastore, ) } diff --git a/edifikana/back-end/src/test/resources/requests/create_unit_request.json b/edifikana/back-end/src/test/resources/requests/create_unit_request.json new file mode 100644 index 000000000..f66a29ba7 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/create_unit_request.json @@ -0,0 +1,10 @@ +{ + "property_id": "property123", + "org_id": "org123", + "unit_number": "101A", + "bedrooms": 2, + "bathrooms": 1, + "sq_ft": 750, + "floor": 1, + "notes": null +} \ No newline at end of file diff --git a/edifikana/back-end/src/test/resources/requests/create_unit_response.json b/edifikana/back-end/src/test/resources/requests/create_unit_response.json new file mode 100644 index 000000000..dd5592ec6 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/create_unit_response.json @@ -0,0 +1,10 @@ +{ + "unit_id": "unit123", + "property_id": "property123", + "org_id": "org123", + "unit_number": "101A", + "bedrooms": 2, + "bathrooms": 1, + "sq_ft": 750, + "floor": 1 +} \ No newline at end of file diff --git a/edifikana/back-end/src/test/resources/requests/get_unit_response.json b/edifikana/back-end/src/test/resources/requests/get_unit_response.json new file mode 100644 index 000000000..dd5592ec6 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/get_unit_response.json @@ -0,0 +1,10 @@ +{ + "unit_id": "unit123", + "property_id": "property123", + "org_id": "org123", + "unit_number": "101A", + "bedrooms": 2, + "bathrooms": 1, + "sq_ft": 750, + "floor": 1 +} \ No newline at end of file diff --git a/edifikana/back-end/src/test/resources/requests/get_units_response.json b/edifikana/back-end/src/test/resources/requests/get_units_response.json new file mode 100644 index 000000000..70679b318 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/get_units_response.json @@ -0,0 +1,25 @@ +{ + "units": [ + { + "unit_id": "unit123", + "property_id": "property123", + "org_id": "org123", + "unit_number": "101A", + "bedrooms": 2, + "bathrooms": 1, + "sq_ft": 750, + "floor": 1 + }, + { + "unit_id": "unit456", + "property_id": "property123", + "org_id": "org123", + "unit_number": "202B", + "bedrooms": 3, + "bathrooms": 2, + "sq_ft": 1100, + "floor": 2, + "notes": "Corner unit" + } + ] +} \ No newline at end of file diff --git a/edifikana/back-end/src/test/resources/requests/update_unit_request.json b/edifikana/back-end/src/test/resources/requests/update_unit_request.json new file mode 100644 index 000000000..b12a640c5 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/update_unit_request.json @@ -0,0 +1,8 @@ +{ + "unit_number": "101B", + "bedrooms": 3, + "bathrooms": 2, + "sq_ft": 900, + "floor": 1, + "notes": "Renovated" +} \ No newline at end of file diff --git a/edifikana/back-end/src/test/resources/requests/update_unit_response.json b/edifikana/back-end/src/test/resources/requests/update_unit_response.json new file mode 100644 index 000000000..8f7fa0316 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/update_unit_response.json @@ -0,0 +1,11 @@ +{ + "unit_id": "unit123", + "property_id": "property123", + "org_id": "org123", + "unit_number": "101B", + "bedrooms": 3, + "bathrooms": 2, + "sq_ft": 900, + "floor": 1, + "notes": "Renovated" +} \ No newline at end of file diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/UnitModel.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/UnitModel.kt new file mode 100644 index 000000000..ca03334e2 --- /dev/null +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/UnitModel.kt @@ -0,0 +1,16 @@ +package com.cramsan.edifikana.lib.model + +/** + * Domain model representing a property unit. + */ +data class UnitModel( + val id: UnitId, + val propertyId: PropertyId, + val orgId: OrganizationId, + val unitNumber: String, + val bedrooms: Int?, + val bathrooms: Int?, + val sqFt: Int?, + val floor: Int?, + val notes: String?, +) diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/GetUnitsQueryParams.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/GetUnitsQueryParams.kt new file mode 100644 index 000000000..3ca0465c4 --- /dev/null +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/GetUnitsQueryParams.kt @@ -0,0 +1,18 @@ +package com.cramsan.edifikana.lib.model.network + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.QueryParam +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Query parameters for listing units. + */ +@NetworkModel +@Serializable +data class GetUnitsQueryParams( + @SerialName("org_id") val orgId: OrganizationId, + @SerialName("property_id") val propertyId: PropertyId? = null, +) : QueryParam From 8c08acb338a8b373a00acb426e70881d57e5e76c Mon Sep 17 00:00:00 2001 From: Cesar Ramirez Date: Sat, 28 Mar 2026 08:09:24 -0700 Subject: [PATCH 2/2] =?UTF-8?q?[EDIFIKANA]=20#386=20Unit=20CRUD=20API=20?= =?UTF-8?q?=E2=80=94=20tests,=20purge,=20and=20null-update=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UnitServiceTest covering all five CRUD operations including null-return and failure paths for getUnit - Add not-found test to UnitControllerTest; fix UnitController.getUnit to throw NotFoundException instead of returning null (null body causes 500 in the framework serializer) - Add purgeUnit to UnitDatastore interface and SupabaseUnitDatastore implementation (hard-delete of soft-deleted records, for test cleanup) - Add SupabaseUnitDatastoreIntegrationTest with eight tests covering create, get, list (full and property-filtered), update, delete, and not-found edge cases - Extend SupabaseIntegrationTest base class with unitDatastore, unit resource tracking, createTestUnit helper, and tearDown cleanup - Document null-means-no-change semantics on UpdateUnitNetworkRequest and SupabaseUnitDatastore.updateUnit Co-Authored-By: Claude Sonnet 4.6 --- .../supabase/SupabaseIntegrationTest.kt | 40 +++ .../SupabaseUnitDatastoreIntegrationTest.kt | 257 +++++++++++++++++ .../server/controller/UnitController.kt | 4 +- .../server/datastore/UnitDatastore.kt | 8 + .../supabase/SupabaseUnitDatastore.kt | 31 +++ .../server/controller/UnitControllerTest.kt | 23 ++ .../server/service/UnitServiceTest.kt | 261 ++++++++++++++++++ .../model/network/UpdateUnitNetworkRequest.kt | 4 + 8 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastoreIntegrationTest.kt create mode 100644 edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UnitServiceTest.kt diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt index 933a479bc..834764961 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt @@ -11,6 +11,7 @@ import com.cramsan.edifikana.lib.model.NotificationId import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.PropertyId import com.cramsan.edifikana.lib.model.TimeCardEventId +import com.cramsan.edifikana.lib.model.UnitId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.lib.utils.requireSuccess import com.cramsan.edifikana.server.dependencyinjection.DatastoreModule @@ -22,6 +23,7 @@ import com.cramsan.edifikana.server.service.models.Notification import com.cramsan.edifikana.server.service.models.Organization import com.cramsan.edifikana.server.service.models.Property import com.cramsan.edifikana.server.service.models.TimeCardEvent +import com.cramsan.edifikana.server.service.models.Unit import com.cramsan.edifikana.lib.model.InviteRole import com.cramsan.edifikana.server.service.models.User import com.cramsan.framework.test.CoroutineTest @@ -52,6 +54,7 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { protected val organizationDatastore: SupabaseOrganizationDatastore by inject() protected val notificationDatastore: SupabaseNotificationDatastore by inject() protected val membershipDatastore: SupabaseMembershipDatastore by inject() + protected val unitDatastore: SupabaseUnitDatastore by inject() private val eventLogResources = mutableSetOf() private val propertyResources = mutableSetOf() @@ -62,6 +65,7 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { private val organizationResources = mutableSetOf() private val invitationResources = mutableSetOf() private val notificationResources = mutableSetOf() + private val unitResources = mutableSetOf() @BeforeEach fun supabaseSetup() { @@ -108,6 +112,10 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { notificationResources.add(notificationId) } + private fun registerUnitForDeletion(unitId: UnitId) { + unitResources.add(unitId) + } + protected fun createTestUser(email: String): UserId { val userId = runBlocking { userDatastore.createUser( @@ -137,6 +145,27 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { return organizationId } + protected fun createTestUnit( + propertyId: PropertyId, + orgId: OrganizationId, + unitNumber: String, + ): UnitId { + val unitId = runBlocking { + unitDatastore.createUnit( + propertyId = propertyId, + orgId = orgId, + unitNumber = unitNumber, + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ).getOrThrow().id + } + registerUnitForDeletion(unitId) + return unitId + } + protected fun createTestProperty(name: String, userId: UserId, organizationId: OrganizationId): PropertyId { val propertyId = runBlocking { propertyDatastore.createProperty( @@ -217,6 +246,12 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { } } + fun Result.registerUnitForDeletion(): Result { + return this.onSuccess { unit -> + registerUnitForDeletion(unit.id) + } + } + fun registerSupabaseUserForDeletion(userId: String) { supabaseUsers.add(userId) } @@ -271,6 +306,10 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { results += employeeDatastore.deleteEmployee(it) results += employeeDatastore.purgeEmployee(it) } + unitResources.forEach { + results += unitDatastore.deleteUnit(it) + results += unitDatastore.purgeUnit(it) + } propertyResources.forEach { results += propertyDatastore.deleteProperty(it) results += propertyDatastore.purgeProperty(it) @@ -293,6 +332,7 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { eventLogResources.clear() timeCardResources.clear() employeeResources.clear() + unitResources.clear() propertyResources.clear() userResources.clear() organizationResources.clear() diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastoreIntegrationTest.kt new file mode 100644 index 000000000..536915ed2 --- /dev/null +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastoreIntegrationTest.kt @@ -0,0 +1,257 @@ +package com.cramsan.edifikana.server.datastore.supabase + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.lib.model.UserId +import com.cramsan.edifikana.server.service.models.Unit +import com.cramsan.framework.utils.uuid.UUID +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SupabaseUnitDatastoreIntegrationTest : SupabaseIntegrationTest() { + private lateinit var test_prefix: String + private var testUserId: UserId? = null + private var testOrg: OrganizationId? = null + private var testProperty: PropertyId? = null + + @BeforeTest + fun setup() { + test_prefix = UUID.random() + testUserId = createTestUser("user-${test_prefix}@test.com") + testOrg = createTestOrganization("org-${test_prefix}", "") + testProperty = createTestProperty("property-${test_prefix}", testUserId!!, testOrg!!) + } + + @Test + fun `createUnit should return unit on success`() = runCoroutineTest { + // Act + val result = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_101", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = "Test unit", + ).registerUnitForDeletion() + + // Assert + assertTrue(result.isSuccess) + val unit = result.getOrNull() + assertNotNull(unit) + assertEquals("${test_prefix}_101", unit.unitNumber) + assertEquals(testProperty!!, unit.propertyId) + assertEquals(testOrg!!, unit.orgId) + assertEquals(2, unit.bedrooms) + assertEquals(1, unit.bathrooms) + assertEquals(750, unit.sqFt) + assertEquals(1, unit.floor) + assertEquals("Test unit", unit.notes) + } + + @Test + fun `getUnit should return created unit`() = runCoroutineTest { + // Arrange + val createResult = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_102", + bedrooms = 3, + bathrooms = 2, + sqFt = 1100, + floor = 2, + notes = null, + ).registerUnitForDeletion() + assertTrue(createResult.isSuccess) + val created = createResult.getOrNull()!! + + // Act + val getResult = unitDatastore.getUnit(created.id) + + // Assert + assertTrue(getResult.isSuccess) + val fetched = getResult.getOrNull() + assertNotNull(fetched) + assertEquals("${test_prefix}_102", fetched.unitNumber) + assertEquals(3, fetched.bedrooms) + assertEquals(2, fetched.bathrooms) + assertEquals(1100, fetched.sqFt) + assertEquals(2, fetched.floor) + assertNull(fetched.notes) + } + + @Test + fun `getUnit should return null for non-existent unit`() = runCoroutineTest { + // Arrange + val fakeId = UnitId(UUID.random()) + + // Act + val result = unitDatastore.getUnit(fakeId) + + // Assert + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + + @Test + fun `getUnits should return all units for org`() = runCoroutineTest { + // Arrange + val result1 = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_201", + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ).registerUnitForDeletion() + val result2 = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_202", + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ).registerUnitForDeletion() + assertTrue(result1.isSuccess) + assertTrue(result2.isSuccess) + + // Act + val listResult = unitDatastore.getUnits(orgId = testOrg!!, propertyId = null) + + // Assert + assertTrue(listResult.isSuccess) + val units = listResult.getOrNull() + assertNotNull(units) + val unitNumbers = units.map { it.unitNumber } + assertTrue(unitNumbers.contains("${test_prefix}_201")) + assertTrue(unitNumbers.contains("${test_prefix}_202")) + } + + @Test + fun `getUnits should filter by propertyId`() = runCoroutineTest { + // Arrange — create a second property with its own unit + val otherProperty = createTestProperty("other-property-${test_prefix}", testUserId!!, testOrg!!) + val result1 = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_target", + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ).registerUnitForDeletion() + val result2 = unitDatastore.createUnit( + propertyId = otherProperty, + orgId = testOrg!!, + unitNumber = "${test_prefix}_other", + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ).registerUnitForDeletion() + assertTrue(result1.isSuccess) + assertTrue(result2.isSuccess) + + // Act + val listResult = unitDatastore.getUnits(orgId = testOrg!!, propertyId = testProperty!!) + + // Assert + assertTrue(listResult.isSuccess) + val unitNumbers = listResult.getOrNull()!!.map { it.unitNumber } + assertTrue(unitNumbers.contains("${test_prefix}_target")) + assertTrue(!unitNumbers.contains("${test_prefix}_other")) + } + + @Test + fun `updateUnit should update provided fields`() = runCoroutineTest { + // Arrange + val createResult = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_301", + bedrooms = 1, + bathrooms = 1, + sqFt = 500, + floor = 3, + notes = null, + ).registerUnitForDeletion() + assertTrue(createResult.isSuccess) + val unit = createResult.getOrNull()!! + + // Act + val updateResult = unitDatastore.updateUnit( + unitId = unit.id, + unitNumber = "${test_prefix}_301B", + bedrooms = 2, + bathrooms = null, + sqFt = null, + floor = null, + notes = "Updated", + ) + + // Assert + assertTrue(updateResult.isSuccess) + val updated = updateResult.getOrNull() + assertNotNull(updated) + assertEquals("${test_prefix}_301B", updated.unitNumber) + assertEquals(2, updated.bedrooms) + assertEquals(1, updated.bathrooms) // unchanged + assertEquals(500, updated.sqFt) // unchanged + assertEquals(3, updated.floor) // unchanged + assertEquals("Updated", updated.notes) + } + + @Test + fun `deleteUnit should soft-delete the unit`() = runCoroutineTest { + // Arrange + val createResult = unitDatastore.createUnit( + propertyId = testProperty!!, + orgId = testOrg!!, + unitNumber = "${test_prefix}_401", + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ) + assertTrue(createResult.isSuccess) + val unit = createResult.getOrNull()!! + + // Act + val deleteResult = unitDatastore.deleteUnit(unit.id) + + // Assert + assertTrue(deleteResult.isSuccess) + assertTrue(deleteResult.getOrNull() == true) + val getResult = unitDatastore.getUnit(unit.id) + assertTrue(getResult.isSuccess) + assertNull(getResult.getOrNull()) + + // Clean up (purge the soft-deleted record) + unitDatastore.purgeUnit(unit.id) + } + + @Test + fun `deleteUnit should return false for non-existent unit`() = runCoroutineTest { + // Arrange + val fakeId = UnitId(UUID.random()) + + // Act + val deleteResult = unitDatastore.deleteUnit(fakeId) + + // Assert + assertTrue(deleteResult.isFailure || deleteResult.getOrNull() == false) + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt index 3aa2e9787..679c00183 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/UnitController.kt @@ -22,6 +22,7 @@ import com.cramsan.framework.core.ktor.OperationRequest import com.cramsan.framework.core.ktor.auth.ClientContext import com.cramsan.framework.core.ktor.auth.ContextRetriever import com.cramsan.framework.core.ktor.handler +import com.cramsan.framework.utils.exceptions.ClientRequestExceptions.NotFoundException import com.cramsan.framework.utils.exceptions.ClientRequestExceptions.UnauthorizedException import io.ktor.server.routing.Routing @@ -73,12 +74,13 @@ class UnitController( UnitId, ClientContext.AuthenticatedClientContext > - ): UnitNetworkResponse? { + ): UnitNetworkResponse { val unitId = request.pathParam if (!rbacService.hasRoleOrHigher(request.context, unitId, UserRole.EMPLOYEE)) { throw UnauthorizedException(unauthorizedMsg) } return unitService.getUnit(unitId)?.toUnitNetworkResponse() + ?: throw NotFoundException("Unit not found: $unitId") } /** diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt index b06737a29..a37cc139a 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/UnitDatastore.kt @@ -54,4 +54,12 @@ interface UnitDatastore { * Soft-deletes the unit with the given [unitId]. Returns true if the record was deleted. */ suspend fun deleteUnit(unitId: UnitId): Result + + /** + * Permanently deletes a soft-deleted unit record by [unitId]. + * Only purges if the record is already soft-deleted. + * This is intended for testing and maintenance purposes only. + * Returns the [Result] of the operation with a [Boolean] indicating if the record was purged. + */ + suspend fun purgeUnit(unitId: UnitId): Result } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt index 3b5f21603..671eed9f5 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseUnitDatastore.kt @@ -86,6 +86,11 @@ class SupabaseUnitDatastore( /** * Updates the fields of an existing unit. Returns the updated [Unit]. + * + * Null parameters are treated as "no change" — passing null for a field leaves the existing + * database value intact. This means it is not possible to explicitly clear a nullable field + * (e.g. set [notes] back to null) through this method. Callers that need to clear a field + * must use a dedicated operation. */ @OptIn(SupabaseModel::class) override suspend fun updateUnit( @@ -131,6 +136,32 @@ class SupabaseUnitDatastore( }.decodeSingleOrNull() != null } + /** + * Permanently deletes a soft-deleted unit by [unitId]. Returns true if the record was purged. + * Only purges records that are already soft-deleted (deletedAt is not null). + */ + @OptIn(SupabaseModel::class) + override suspend fun purgeUnit(unitId: UnitId): Result = runSuspendCatching(TAG) { + logD(TAG, "Purging soft-deleted unit: %s", unitId) + + val entity = postgrest.from(UnitEntity.COLLECTION).select { + filter { + UnitEntity::unitId eq unitId.unitId + } + }.decodeSingleOrNull() + + if (entity?.deletedAt == null) { + return@runSuspendCatching false + } + + postgrest.from(UnitEntity.COLLECTION).delete { + filter { + UnitEntity::unitId eq unitId.unitId + } + } + true + } + companion object { const val TAG = "SupabaseUnitDatastore" } diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt index 40f336f8b..52b43052f 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/UnitControllerTest.kt @@ -178,6 +178,29 @@ class UnitControllerTest : CoroutineTest(), KoinTest { assertEquals(expectedResponse, response.bodyAsText()) } + @Test + fun `test getUnit returns not found when unit does not exist`() = testBackEndApplication { + // Arrange + val unitService = get() + val rbacService = get() + val unitId = UnitId("unit123") + coEvery { unitService.getUnit(unitId) }.answers { null } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload(userInfo = mockk(), userId = UserId("user123")) + ) + coEvery { contextRetriever.getContext(any()) }.answers { context } + coEvery { + rbacService.hasRoleOrHigher(context, unitId, UserRole.EMPLOYEE) + }.answers { true } + + // Act + val response = client.get("unit/unit123") + + // Assert + assertEquals(HttpStatusCode.NotFound, response.status) + } + @Test fun `test getUnit fails when user lacks required role`() = testBackEndApplication { // Arrange diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UnitServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UnitServiceTest.kt new file mode 100644 index 000000000..7bd18c9c7 --- /dev/null +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/UnitServiceTest.kt @@ -0,0 +1,261 @@ +package com.cramsan.edifikana.server.service + +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UnitId +import com.cramsan.edifikana.server.datastore.UnitDatastore +import com.cramsan.edifikana.server.service.models.Unit +import com.cramsan.framework.logging.EventLogger +import com.cramsan.framework.logging.implementation.PassthroughEventLogger +import com.cramsan.framework.logging.implementation.StdOutEventLoggerDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.core.context.stopKoin +import kotlin.test.AfterTest + +/** + * Test class for [UnitService]. + */ +class UnitServiceTest { + private lateinit var unitDatastore: UnitDatastore + private lateinit var unitService: UnitService + + @BeforeEach + fun setUp() { + EventLogger.setInstance(PassthroughEventLogger(StdOutEventLoggerDelegate())) + unitDatastore = mockk() + unitService = UnitService(unitDatastore) + } + + @AfterTest + fun cleanUp() { + stopKoin() + } + + /** + * Tests that createUnit calls the datastore with all parameters and returns the result. + */ + @Test + fun `createUnit should call unitDatastore and return unit`() = runTest { + // Arrange + val unit = mockk() + coEvery { + unitDatastore.createUnit(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Result.success(unit) + + // Act + val result = unitService.createUnit( + propertyId = PropertyId("prop1"), + orgId = OrganizationId("org1"), + unitNumber = "101", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = null, + ) + + // Assert + assertEquals(unit, result) + coVerify { + unitDatastore.createUnit( + propertyId = PropertyId("prop1"), + orgId = OrganizationId("org1"), + unitNumber = "101", + bedrooms = 2, + bathrooms = 1, + sqFt = 750, + floor = 1, + notes = null, + ) + } + } + + /** + * Tests that getUnit returns the unit when the datastore finds it. + */ + @Test + fun `getUnit should call unitDatastore and return unit when found`() = runTest { + // Arrange + val unitId = UnitId("unit1") + val unit = mockk() + coEvery { unitDatastore.getUnit(unitId) } returns Result.success(unit) + + // Act + val result = unitService.getUnit(unitId) + + // Assert + assertEquals(unit, result) + coVerify { unitDatastore.getUnit(unitId) } + } + + /** + * Tests that getUnit returns null when the datastore returns a null result. + */ + @Test + fun `getUnit should return null when unit does not exist`() = runTest { + // Arrange + val unitId = UnitId("missing") + coEvery { unitDatastore.getUnit(unitId) } returns Result.success(null) + + // Act + val result = unitService.getUnit(unitId) + + // Assert + assertNull(result) + coVerify { unitDatastore.getUnit(unitId) } + } + + /** + * Tests that getUnit returns null when the datastore returns a failure. + */ + @Test + fun `getUnit should return null when datastore fails`() = runTest { + // Arrange + val unitId = UnitId("missing") + coEvery { unitDatastore.getUnit(unitId) } returns Result.failure(Exception("Not found")) + + // Act + val result = unitService.getUnit(unitId) + + // Assert + assertNull(result) + coVerify { unitDatastore.getUnit(unitId) } + } + + /** + * Tests that getUnits returns the list of units for an org, filtered by property. + */ + @Test + fun `getUnits should call unitDatastore with propertyId filter and return list`() = runTest { + // Arrange + val orgId = OrganizationId("org1") + val propertyId = PropertyId("prop1") + val units = listOf(mockk(), mockk()) + coEvery { unitDatastore.getUnits(orgId, propertyId) } returns Result.success(units) + + // Act + val result = unitService.getUnits(orgId, propertyId) + + // Assert + assertEquals(units, result) + coVerify { unitDatastore.getUnits(orgId, propertyId) } + } + + /** + * Tests that getUnits returns all org units when propertyId is null. + */ + @Test + fun `getUnits should call unitDatastore without property filter when null`() = runTest { + // Arrange + val orgId = OrganizationId("org1") + val units = listOf(mockk()) + coEvery { unitDatastore.getUnits(orgId, null) } returns Result.success(units) + + // Act + val result = unitService.getUnits(orgId, null) + + // Assert + assertEquals(units, result) + coVerify { unitDatastore.getUnits(orgId, null) } + } + + /** + * Tests that updateUnit calls the datastore with all provided parameters and returns the result. + */ + @Test + fun `updateUnit should call unitDatastore and return updated unit`() = runTest { + // Arrange + val unitId = UnitId("unit1") + val unit = mockk() + coEvery { + unitDatastore.updateUnit(any(), any(), any(), any(), any(), any(), any()) + } returns Result.success(unit) + + // Act + val result = unitService.updateUnit( + unitId = unitId, + unitNumber = "101B", + bedrooms = 3, + bathrooms = 2, + sqFt = 900, + floor = 1, + notes = "Renovated", + ) + + // Assert + assertEquals(unit, result) + coVerify { + unitDatastore.updateUnit( + unitId = unitId, + unitNumber = "101B", + bedrooms = 3, + bathrooms = 2, + sqFt = 900, + floor = 1, + notes = "Renovated", + ) + } + } + + /** + * Tests that null fields in updateUnit are forwarded to the datastore as-is (no change semantics). + */ + @Test + fun `updateUnit should forward null fields to datastore unchanged`() = runTest { + // Arrange + val unitId = UnitId("unit1") + val unit = mockk() + coEvery { + unitDatastore.updateUnit(any(), any(), any(), any(), any(), any(), any()) + } returns Result.success(unit) + + // Act + val result = unitService.updateUnit( + unitId = unitId, + unitNumber = null, + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ) + + // Assert + assertEquals(unit, result) + coVerify { + unitDatastore.updateUnit( + unitId = unitId, + unitNumber = null, + bedrooms = null, + bathrooms = null, + sqFt = null, + floor = null, + notes = null, + ) + } + } + + /** + * Tests that deleteUnit calls the datastore and returns the success result. + */ + @Test + fun `deleteUnit should call unitDatastore and return true`() = runTest { + // Arrange + val unitId = UnitId("unit1") + coEvery { unitDatastore.deleteUnit(unitId) } returns Result.success(true) + + // Act + val result = unitService.deleteUnit(unitId) + + // Assert + assertEquals(true, result) + coVerify { unitDatastore.deleteUnit(unitId) } + } +} diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateUnitNetworkRequest.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateUnitNetworkRequest.kt index c4bce0fb0..d51fac8c1 100644 --- a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateUnitNetworkRequest.kt +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateUnitNetworkRequest.kt @@ -7,6 +7,10 @@ import kotlinx.serialization.Serializable /** * Request to update an existing unit. + * + * All fields are optional. A null value means "leave unchanged" — the existing database value + * is preserved. It is not possible to explicitly clear a nullable field (e.g. reset [notes] to + * null) through this request. */ @NetworkModel @Serializable