Initial commit
This commit is contained in:
62
modules/directory/build.gradle.kts
Normal file
62
modules/directory/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.directory"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
kapt(Dependencies.roomCompiler)
|
||||
|
||||
// Database Encryption
|
||||
implementation(Dependencies.sqlcipher)
|
||||
|
||||
// Testing
|
||||
testImplementation(Dependencies.junit)
|
||||
testImplementation(Dependencies.mockk)
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
testImplementation(Dependencies.truth)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.smoa.modules.directory
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.directory.domain.DirectoryEntry
|
||||
import com.smoa.modules.directory.domain.DirectoryService
|
||||
import com.smoa.modules.directory.ui.DirectoryListScreen
|
||||
|
||||
/**
|
||||
* Directory module - Controlled access to internal routing and contact information.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectoryModule(
|
||||
directoryService: DirectoryService,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
onEntryClick: (DirectoryEntry) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
DirectoryListScreen(
|
||||
directoryService = directoryService,
|
||||
userRole = userRole,
|
||||
userUnit = userUnit,
|
||||
onEntryClick = onEntryClick,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Data Access Object for directory entries.
|
||||
*/
|
||||
@Dao
|
||||
interface DirectoryDao {
|
||||
/**
|
||||
* Get all directory entries.
|
||||
*/
|
||||
@Query("SELECT * FROM directory_entries ORDER BY name ASC")
|
||||
fun observeAllEntries(): Flow<List<DirectoryEntity>>
|
||||
|
||||
/**
|
||||
* Get directory entry by ID.
|
||||
*/
|
||||
@Query("SELECT * FROM directory_entries WHERE id = :entryId")
|
||||
suspend fun getEntryById(entryId: String): DirectoryEntity?
|
||||
|
||||
/**
|
||||
* Search directory entries by name, title, or unit.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT * FROM directory_entries
|
||||
WHERE name LIKE :query
|
||||
OR title LIKE :query
|
||||
OR unit LIKE :query
|
||||
ORDER BY name ASC
|
||||
""")
|
||||
suspend fun searchDirectory(query: String): List<DirectoryEntity>
|
||||
|
||||
/**
|
||||
* Get directory entries by unit.
|
||||
*/
|
||||
@Query("SELECT * FROM directory_entries WHERE unit = :unit ORDER BY name ASC")
|
||||
suspend fun getEntriesByUnit(unit: String): List<DirectoryEntity>
|
||||
|
||||
/**
|
||||
* Insert or update directory entry.
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertEntry(entry: DirectoryEntity)
|
||||
|
||||
/**
|
||||
* Update directory entry.
|
||||
*/
|
||||
@Update
|
||||
suspend fun updateEntry(entry: DirectoryEntity)
|
||||
|
||||
/**
|
||||
* Delete directory entry.
|
||||
*/
|
||||
@Query("DELETE FROM directory_entries WHERE id = :entryId")
|
||||
suspend fun deleteEntry(entryId: String)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* Directory database.
|
||||
*/
|
||||
@Database(
|
||||
entities = [DirectoryEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class DirectoryDatabase : RoomDatabase() {
|
||||
abstract fun directoryDao(): DirectoryDao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.smoa.core.security.EncryptedDatabaseHelper
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DirectoryDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDirectoryDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
encryptedDatabaseHelper: EncryptedDatabaseHelper
|
||||
): DirectoryDatabase {
|
||||
val factory = encryptedDatabaseHelper.createOpenHelperFactory("directory_database")
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
DirectoryDatabase::class.java,
|
||||
"directory_database"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideDirectoryDao(database: DirectoryDatabase): DirectoryDao {
|
||||
return database.directoryDao()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.smoa.modules.directory.domain.DirectoryEntry
|
||||
|
||||
/**
|
||||
* Directory entity for Room database.
|
||||
*/
|
||||
@Entity(tableName = "directory_entries")
|
||||
data class DirectoryEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val title: String?,
|
||||
val unit: String,
|
||||
val phoneNumber: String?,
|
||||
val extension: String?,
|
||||
val email: String?,
|
||||
val secureRoutingId: String?,
|
||||
val role: String?,
|
||||
val clearanceLevel: String?,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Convert entity to domain model.
|
||||
*/
|
||||
fun DirectoryEntity.toDomain(): com.smoa.modules.directory.domain.DirectoryEntry {
|
||||
return com.smoa.modules.directory.domain.DirectoryEntry(
|
||||
id = id,
|
||||
name = name,
|
||||
title = title,
|
||||
unit = unit,
|
||||
phoneNumber = phoneNumber,
|
||||
extension = extension,
|
||||
email = email,
|
||||
secureRoutingId = secureRoutingId,
|
||||
role = role,
|
||||
clearanceLevel = clearanceLevel,
|
||||
lastUpdated = lastUpdated
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain model to entity.
|
||||
*/
|
||||
fun com.smoa.modules.directory.domain.DirectoryEntry.toEntity(): DirectoryEntity {
|
||||
return DirectoryEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
title = title,
|
||||
unit = unit,
|
||||
phoneNumber = phoneNumber,
|
||||
extension = extension,
|
||||
email = email,
|
||||
secureRoutingId = secureRoutingId,
|
||||
role = role,
|
||||
clearanceLevel = clearanceLevel,
|
||||
lastUpdated = lastUpdated
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.smoa.modules.directory.di
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.directory.data.DirectoryDao
|
||||
import com.smoa.modules.directory.domain.DirectoryService
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DirectoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDirectoryService(
|
||||
directoryDao: DirectoryDao,
|
||||
rbacFramework: RBACFramework
|
||||
): DirectoryService {
|
||||
return DirectoryService(directoryDao, rbacFramework)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.smoa.modules.directory.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.modules.directory.data.DirectoryDao
|
||||
import com.smoa.modules.directory.data.DirectoryEntity
|
||||
import com.smoa.modules.directory.data.toDomain
|
||||
import com.smoa.modules.directory.data.toEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Directory service for managing internal directory and contact information.
|
||||
* Enforces unit-scoped and role-scoped directory views.
|
||||
*/
|
||||
@Singleton
|
||||
class DirectoryService @Inject constructor(
|
||||
private val directoryDao: DirectoryDao,
|
||||
private val rbacFramework: RBACFramework
|
||||
) {
|
||||
/**
|
||||
* Search directory entries.
|
||||
* Results are filtered by user's role and unit scope.
|
||||
*/
|
||||
suspend fun searchDirectory(
|
||||
query: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<DirectoryEntry> {
|
||||
val entries = directoryDao.searchDirectory("%$query%")
|
||||
|
||||
// Filter by role and unit scope
|
||||
val filtered = entries.filter { entry ->
|
||||
// Check if user has permission to view this entry
|
||||
hasAccessToEntry(entry, userRole, userUnit)
|
||||
}
|
||||
return filtered.map { entry: DirectoryEntity -> entry.toDomain() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory entry by ID.
|
||||
*/
|
||||
suspend fun getDirectoryEntry(
|
||||
entryId: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): DirectoryEntry? {
|
||||
val entity = directoryDao.getEntryById(entryId) ?: return null
|
||||
|
||||
// Check access
|
||||
if (!hasAccessToEntry(entity, userRole, userUnit)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return entity.toDomain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all directory entries for a unit.
|
||||
*/
|
||||
suspend fun getDirectoryEntriesByUnit(
|
||||
unit: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<DirectoryEntry> {
|
||||
// Check if user has access to this unit
|
||||
if (userUnit != null && userUnit != unit && userRole != RBACFramework.Role.ADMIN) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return directoryDao.getEntriesByUnit(unit).map { it.toDomain() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory entries observable (for UI).
|
||||
*/
|
||||
fun observeDirectoryEntries(
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Flow<List<DirectoryEntry>> {
|
||||
return directoryDao.observeAllEntries()
|
||||
.map { entities ->
|
||||
entities
|
||||
.filter { hasAccessToEntry(it, userRole, userUnit) }
|
||||
.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to a directory entry.
|
||||
*/
|
||||
private fun hasAccessToEntry(
|
||||
entry: DirectoryEntity,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Boolean {
|
||||
// Admins can see all entries
|
||||
if (userRole == RBACFramework.Role.ADMIN) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check unit scope
|
||||
if (userUnit != null && entry.unit != userUnit) {
|
||||
// User can only see entries from their unit
|
||||
return false
|
||||
}
|
||||
|
||||
// Check role permissions
|
||||
return rbacFramework.hasPermission(userRole, RBACFramework.Permission.VIEW_DIRECTORY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update directory entry (admin only).
|
||||
*/
|
||||
suspend fun upsertDirectoryEntry(
|
||||
entry: DirectoryEntry,
|
||||
userRole: RBACFramework.Role
|
||||
): Result<DirectoryEntry> {
|
||||
if (userRole != RBACFramework.Role.ADMIN) {
|
||||
return Result.Error(SecurityException("Only administrators can modify directory entries"))
|
||||
}
|
||||
|
||||
return try {
|
||||
val entity = entry.toEntity()
|
||||
directoryDao.upsertEntry(entity)
|
||||
Result.Success(entry)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete directory entry (admin only).
|
||||
*/
|
||||
suspend fun deleteDirectoryEntry(
|
||||
entryId: String,
|
||||
userRole: RBACFramework.Role
|
||||
): Result<Unit> {
|
||||
if (userRole != RBACFramework.Role.ADMIN) {
|
||||
return Result.Error(SecurityException("Only administrators can delete directory entries"))
|
||||
}
|
||||
|
||||
return try {
|
||||
directoryDao.deleteEntry(entryId)
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry domain model.
|
||||
*/
|
||||
data class DirectoryEntry(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val title: String?,
|
||||
val unit: String,
|
||||
val phoneNumber: String?,
|
||||
val extension: String?,
|
||||
val email: String?,
|
||||
val secureRoutingId: String?,
|
||||
val role: String?,
|
||||
val clearanceLevel: String?,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.smoa.modules.directory.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.smoa.modules.directory.domain.DirectoryEntry
|
||||
import com.smoa.modules.directory.domain.DirectoryService
|
||||
|
||||
/**
|
||||
* Directory list screen with search functionality.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DirectoryListScreen(
|
||||
directoryService: DirectoryService,
|
||||
userRole: com.smoa.core.auth.RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
onEntryClick: (DirectoryEntry) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var directoryEntries by remember { mutableStateOf<List<DirectoryEntry>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Observe directory entries
|
||||
LaunchedEffect(userRole, userUnit) {
|
||||
directoryService.observeDirectoryEntries(userRole, userUnit)
|
||||
.collect { entries ->
|
||||
directoryEntries = entries
|
||||
}
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
LaunchedEffect(searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
// Show all entries when search is empty
|
||||
directoryService.observeDirectoryEntries(userRole, userUnit)
|
||||
.collect { entries ->
|
||||
directoryEntries = entries
|
||||
}
|
||||
} else {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
try {
|
||||
directoryEntries = directoryService.searchDirectory(searchQuery, userRole, userUnit)
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
label = { Text("Search directory") },
|
||||
placeholder = { Text("Name, title, or unit") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// Directory entries list
|
||||
if (directoryEntries.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (searchQuery.isBlank()) "No directory entries" else "No results found",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(directoryEntries) { entry ->
|
||||
DirectoryEntryCard(
|
||||
entry = entry,
|
||||
onClick = { onEntryClick(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry card.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectoryEntryCard(
|
||||
entry: DirectoryEntry,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
entry.title?.let { title ->
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = entry.unit,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
entry.phoneNumber?.let { phone ->
|
||||
Text(
|
||||
text = phone,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.smoa.modules.directory.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.directory.data.DirectoryDao
|
||||
import com.smoa.modules.directory.data.DirectoryEntity
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for DirectoryService.
|
||||
*/
|
||||
class DirectoryServiceTest {
|
||||
private val directoryDao = mockk<DirectoryDao>(relaxed = true)
|
||||
private val rbacFramework = mockk<RBACFramework>(relaxed = true)
|
||||
private val directoryService = DirectoryService(directoryDao, rbacFramework)
|
||||
|
||||
@Test
|
||||
fun `searchDirectory should filter by role and unit`() = runTest {
|
||||
// Given
|
||||
val query = "test"
|
||||
val userRole = RBACFramework.Role.OPERATOR
|
||||
val userUnit = "Unit1"
|
||||
|
||||
val entity1 = DirectoryEntity(
|
||||
id = "1",
|
||||
name = "Test User 1",
|
||||
title = "Officer",
|
||||
unit = "Unit1",
|
||||
phoneNumber = "123-456-7890",
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
|
||||
val entity2 = DirectoryEntity(
|
||||
id = "2",
|
||||
name = "Test User 2",
|
||||
title = "Officer",
|
||||
unit = "Unit2",
|
||||
phoneNumber = "123-456-7891",
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
|
||||
coEvery { directoryDao.searchDirectory("%$query%") } returns listOf(entity1, entity2)
|
||||
every { rbacFramework.hasPermission(userRole, RBACFramework.Permission.VIEW_DIRECTORY) } returns true
|
||||
|
||||
// When
|
||||
val result = directoryService.searchDirectory(query, userRole, userUnit)
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("Test User 1", result[0].name)
|
||||
assertEquals("Unit1", result[0].unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDirectoryEntry should return null for unauthorized access`() = runTest {
|
||||
// Given
|
||||
val entryId = "1"
|
||||
val userRole = RBACFramework.Role.VIEWER
|
||||
val userUnit = "Unit1"
|
||||
|
||||
val entity = DirectoryEntity(
|
||||
id = entryId,
|
||||
name = "Test User",
|
||||
title = "Officer",
|
||||
unit = "Unit2", // Different unit
|
||||
phoneNumber = null,
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
|
||||
coEvery { directoryDao.getEntryById(entryId) } returns entity
|
||||
every { rbacFramework.hasPermission(userRole, RBACFramework.Permission.VIEW_DIRECTORY) } returns true
|
||||
|
||||
// When
|
||||
val result = directoryService.getDirectoryEntry(entryId, userRole, userUnit)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertDirectoryEntry should fail for non-admin users`() = runTest {
|
||||
// Given
|
||||
val entry = DirectoryEntry(
|
||||
id = "1",
|
||||
name = "Test User",
|
||||
title = "Officer",
|
||||
unit = "Unit1",
|
||||
phoneNumber = null,
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
val userRole = RBACFramework.Role.OPERATOR
|
||||
|
||||
// When
|
||||
val result = directoryService.upsertDirectoryEntry(entry, userRole)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is SecurityException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertDirectoryEntry should succeed for admin users`() = runTest {
|
||||
// Given
|
||||
val entry = DirectoryEntry(
|
||||
id = "1",
|
||||
name = "Test User",
|
||||
title = "Officer",
|
||||
unit = "Unit1",
|
||||
phoneNumber = null,
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
val userRole = RBACFramework.Role.ADMIN
|
||||
|
||||
coEvery { directoryDao.upsertEntry(any()) } returns Unit
|
||||
|
||||
// When
|
||||
val result = directoryService.upsertDirectoryEntry(entry, userRole)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
verify { directoryDao.upsertEntry(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user