Skip to content

Commit fbf6092

Browse files
committed
Enhance test coverage in SyncServiceTest by adding new cases for device registration, deactivation, and removal, including error propagation for unauthorized access and database exceptions. Update TEST_COVERAGE_ACTION_PLAN.md to reflect these improvements. Extend MetadataUseCaseTest with advanced search and metadata retrieval tests, and enhance PluginsViewModelTest with success and error handling scenarios. Revise StringsTest to ensure all languages have consistent string values for various UI elements.
1 parent 7ebea3e commit fbf6092

5 files changed

Lines changed: 424 additions & 6 deletions

File tree

backend/core/sync/src/test/kotlin/com/vaultstadio/core/domain/service/SyncServiceTest.kt

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.vaultstadio.core.domain.model.SyncConflict
1515
import com.vaultstadio.core.domain.model.SyncDevice
1616
import com.vaultstadio.core.domain.model.SyncRequest
1717
import com.vaultstadio.core.domain.repository.SyncRepository
18+
import com.vaultstadio.domain.common.exception.AuthorizationException
1819
import com.vaultstadio.domain.common.exception.DatabaseException
1920
import com.vaultstadio.domain.common.exception.InvalidOperationException
2021
import com.vaultstadio.domain.common.exception.ItemNotFoundException
@@ -275,6 +276,86 @@ class SyncServiceTest {
275276
result.onLeft { err -> assertTrue(err is DatabaseException) }
276277
}
277278

279+
@Test
280+
fun `registerDevice should propagate error when updateDevice returns Left on reactivate`() = runTest {
281+
val userId = "user-1"
282+
val now = Clock.System.now()
283+
val existingDevice = SyncDevice(
284+
id = "existing-id",
285+
userId = userId,
286+
deviceId = "device-123",
287+
deviceName = "Old Name",
288+
deviceType = DeviceType.DESKTOP_MAC,
289+
isActive = false,
290+
createdAt = now,
291+
updatedAt = now,
292+
)
293+
val input = RegisterDeviceInput(
294+
deviceId = "device-123",
295+
deviceName = "New Name",
296+
deviceType = DeviceType.DESKTOP_MAC,
297+
)
298+
coEvery { syncRepository.findDeviceByUserAndId(userId, input.deviceId) } returns existingDevice.right()
299+
coEvery { syncRepository.updateDevice(any()) } returns DatabaseException("update failed").left()
300+
301+
val result = service.registerDevice(input, userId)
302+
303+
assertTrue(result.isLeft())
304+
result.onLeft { err -> assertTrue(err is DatabaseException) }
305+
}
306+
307+
@Test
308+
fun `deactivateDevice should return AuthorizationException when device belongs to another user`() = runTest {
309+
val userId = "user-1"
310+
val otherUserId = "user-2"
311+
val now = Clock.System.now()
312+
val otherUserDevice = SyncDevice(
313+
id = "dev-1",
314+
userId = otherUserId,
315+
deviceId = "device-1",
316+
deviceName = "Other Device",
317+
deviceType = DeviceType.DESKTOP_MAC,
318+
isActive = true,
319+
createdAt = now,
320+
updatedAt = now,
321+
)
322+
coEvery { syncRepository.findDeviceByUserAndId(userId, "device-1") } returns otherUserDevice.right()
323+
324+
val result = service.deactivateDevice("device-1", userId)
325+
326+
assertTrue(result.isLeft())
327+
result.onLeft { err ->
328+
assertTrue(err is AuthorizationException)
329+
assertTrue(err.message!!.contains("Not authorized to deactivate"))
330+
}
331+
}
332+
333+
@Test
334+
fun `removeDevice should return AuthorizationException when device belongs to another user`() = runTest {
335+
val userId = "user-1"
336+
val otherUserId = "user-2"
337+
val now = Clock.System.now()
338+
val otherUserDevice = SyncDevice(
339+
id = "dev-1",
340+
userId = otherUserId,
341+
deviceId = "device-1",
342+
deviceName = "Other Device",
343+
deviceType = DeviceType.DESKTOP_MAC,
344+
isActive = true,
345+
createdAt = now,
346+
updatedAt = now,
347+
)
348+
coEvery { syncRepository.findDeviceByUserAndId(userId, "device-1") } returns otherUserDevice.right()
349+
350+
val result = service.removeDevice("device-1", userId)
351+
352+
assertTrue(result.isLeft())
353+
result.onLeft { err ->
354+
assertTrue(err is AuthorizationException)
355+
assertTrue(err.message!!.contains("Not authorized to remove"))
356+
}
357+
}
358+
278359
@Test
279360
fun `listDevices should propagate error when repository returns Left`() = runTest {
280361
val userId = "user-1"
@@ -335,6 +416,50 @@ class SyncServiceTest {
335416
}
336417
}
337418

419+
@Test
420+
fun `deactivateDevice should succeed when device found and repository returns Right`() = runTest {
421+
val userId = "user-1"
422+
val now = Clock.System.now()
423+
val device = SyncDevice(
424+
id = "dev-1",
425+
userId = userId,
426+
deviceId = "device-1",
427+
deviceName = "Laptop",
428+
deviceType = DeviceType.DESKTOP_MAC,
429+
isActive = true,
430+
createdAt = now,
431+
updatedAt = now,
432+
)
433+
coEvery { syncRepository.findDeviceByUserAndId(userId, "device-1") } returns device.right()
434+
coEvery { syncRepository.deactivateDevice("dev-1") } returns Unit.right()
435+
436+
val result = service.deactivateDevice("device-1", userId)
437+
438+
assertTrue(result.isRight())
439+
}
440+
441+
@Test
442+
fun `removeDevice should succeed when device found and repository returns Right`() = runTest {
443+
val userId = "user-1"
444+
val now = Clock.System.now()
445+
val device = SyncDevice(
446+
id = "dev-1",
447+
userId = userId,
448+
deviceId = "device-1",
449+
deviceName = "Laptop",
450+
deviceType = DeviceType.DESKTOP_MAC,
451+
isActive = true,
452+
createdAt = now,
453+
updatedAt = now,
454+
)
455+
coEvery { syncRepository.findDeviceByUserAndId(userId, "device-1") } returns device.right()
456+
coEvery { syncRepository.removeDevice("dev-1") } returns Unit.right()
457+
458+
val result = service.removeDevice("device-1", userId)
459+
460+
assertTrue(result.isRight())
461+
}
462+
338463
@Test
339464
fun `sync should return ItemNotFoundException when device not found`() = runTest {
340465
val userId = "user-1"
@@ -475,6 +600,45 @@ class SyncServiceTest {
475600
}
476601
}
477602

603+
@Test
604+
fun `resolveConflict should propagate when repository resolveConflict returns Left`() = runTest {
605+
val conflictId = "conflict-1"
606+
val userId = "user-1"
607+
val now = Clock.System.now()
608+
val localChange = SyncChange(
609+
id = "local-1",
610+
itemId = "item-1",
611+
changeType = ChangeType.MODIFY,
612+
userId = userId,
613+
timestamp = now,
614+
cursor = 100,
615+
)
616+
val remoteChange = SyncChange(
617+
id = "remote-1",
618+
itemId = "item-1",
619+
changeType = ChangeType.MODIFY,
620+
userId = userId,
621+
timestamp = now,
622+
cursor = 101,
623+
)
624+
val pendingConflict = SyncConflict(
625+
id = conflictId,
626+
itemId = "item-1",
627+
localChange = localChange,
628+
remoteChange = remoteChange,
629+
conflictType = ConflictType.EDIT_CONFLICT,
630+
createdAt = now,
631+
)
632+
coEvery { syncRepository.findConflict(conflictId) } returns pendingConflict.right()
633+
coEvery { syncRepository.resolveConflict(conflictId, ConflictResolution.KEEP_REMOTE, any()) } returns
634+
DatabaseException("db error").left()
635+
636+
val result = service.resolveConflict(conflictId, ConflictResolution.KEEP_REMOTE, userId)
637+
638+
assertTrue(result.isLeft())
639+
result.onLeft { err -> assertTrue(err is DatabaseException) }
640+
}
641+
478642
@Test
479643
fun `pruneOldData should return sum of pruned changes and conflicts`() = runTest {
480644
coEvery { syncRepository.pruneChanges(any()) } returns 10.right()

docs/development/TEST_COVERAGE_ACTION_PLAN.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,12 @@ Frontend coverage is a single report for **composeApp** (desktop JVM tests); it
9494

9595
---
9696

97-
**Recent coverage improvements:** **Applied (continuation 2026-02-22):** **Backend core (share):** **ShareServiceTest** (GetSharesTests) extended with `should propagate when shareRepository findByCreatedBy returns Left for getSharesByUser`, **getSharesSharedWithUser**`should get shares shared with user`, `should propagate when shareRepository findSharedWithUser returns Left`. —
97+
**Recent coverage improvements:** **Applied (continuation 2026-02-22):** **Backend core (sync):** **SyncServiceTest** extended with `deactivateDevice should succeed when device found and repository returns Right`, `removeDevice should succeed when device found and repository returns Right`. **Frontend composeApp:** **PluginsViewModelTest** extended with `loadPlugins_success_setsPlugins`, `loadPlugins_error_setsError`. —
98+
**Applied (continuation 2026-02-22):** **Backend core (sync):** **SyncServiceTest** extended with `resolveConflict should propagate when repository resolveConflict returns Left`. **Frontend i18n (core/resources):** **StringsTest** extended with **allLanguages_haveCommonBackAndCommonRetry**. —
99+
**Applied (continuation 2026-02-22):** **Backend core (sync):** **SyncServiceTest** extended with `registerDevice should propagate error when updateDevice returns Left on reactivate`, `deactivateDevice should return AuthorizationException when device belongs to another user`, `removeDevice should return AuthorizationException when device belongs to another user`. **Frontend i18n (core/resources):** **StringsTest** extended with **allLanguages_haveActionRestoreAndActionNew**. —
100+
**Applied (continuation 2026-02-22):** **Frontend i18n (core/resources):** **StringsTest** extended with **allLanguages_haveShareTitle**, **allLanguages_haveNavRecentAndNavStarred**, **allLanguages_haveVersionTitleAndSyncTitle**, **allLanguages_haveActivityNoActivity** (all 7 languages non-empty for share, nav recent/starred, version/sync titles, activity empty state). —
101+
**Applied (continuation 2026-02-22):** **Frontend composeApp (domain.usecase.metadata):** **MetadataUseCaseTest** extended with **AdvancedSearchUseCaseTest** (invoke returns repository advancedSearch result, invoke propagates error), **GetImageMetadataUseCaseTest** (invoke returns repository getImageMetadata result, invoke propagates error), **GetVideoMetadataUseCaseTest** (invoke returns repository getVideoMetadata result, invoke propagates error), **GetDocumentMetadataUseCaseTest** (invoke returns repository getDocumentMetadata result, invoke propagates error). FakeMetadataRepository now has configurable advancedSearchResult, getImageMetadataResult, getVideoMetadataResult, getDocumentMetadataResult. Covers all metadata use cases (GetSearchSuggestions, SearchByMetadata, GetFileMetadata, AdvancedSearch, GetImageMetadata, GetVideoMetadata, GetDocumentMetadata). —
102+
**Applied (continuation 2026-02-22):** **Backend core (share):** **ShareServiceTest** (GetSharesTests) extended with `should propagate when shareRepository findByCreatedBy returns Left for getSharesByUser`, **getSharesSharedWithUser**`should get shares shared with user`, `should propagate when shareRepository findSharedWithUser returns Left`. —
98103
**Applied (continuation 2026-02-22):** **Backend core (share):** **ShareServiceTest** (AccessShareTests) extended with `should propagate when shareRepository findByToken returns Left`, `should propagate when storageItemRepository findById returns Left for accessShare`. —
99104
**Applied (continuation 2026-02-22):** **Backend core (share):** **ShareServiceTest** (CreateShareTests) extended with `should propagate when shareRepository create returns Left`. **Frontend i18n (core/resources):** **StringsTest** extended with **allLanguages_haveActionShare**. —
100105
**Applied (continuation 2026-02-22):** **Backend core (share):** **ShareServiceTest** (GetShareTests) extended with `should propagate when shareRepository findById returns Left`. **Frontend i18n (core/resources):** **StringsTest** extended with **allLanguages_haveActionDownload**. —

frontend/composeApp/src/test/kotlin/com/vaultstadio/app/domain/usecase/metadata/MetadataUseCaseTest.kt

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
/**
2-
* Unit tests for metadata use cases (GetSearchSuggestions, SearchByMetadata).
2+
* Unit tests for metadata use cases (GetSearchSuggestions, SearchByMetadata, GetFileMetadata,
3+
* AdvancedSearch, GetImageMetadata, GetVideoMetadata, GetDocumentMetadata).
34
* Uses a fake MetadataRepository to avoid platform/DI.
45
*/
56

67
package com.vaultstadio.app.domain.usecase.metadata
78

9+
import com.vaultstadio.app.data.metadata.usecase.AdvancedSearchUseCaseImpl
10+
import com.vaultstadio.app.data.metadata.usecase.GetDocumentMetadataUseCaseImpl
811
import com.vaultstadio.app.data.metadata.usecase.GetFileMetadataUseCaseImpl
12+
import com.vaultstadio.app.data.metadata.usecase.GetImageMetadataUseCaseImpl
913
import com.vaultstadio.app.data.metadata.usecase.GetSearchSuggestionsUseCaseImpl
14+
import com.vaultstadio.app.data.metadata.usecase.GetVideoMetadataUseCaseImpl
1015
import com.vaultstadio.app.data.metadata.usecase.SearchByMetadataUseCaseImpl
1116
import com.vaultstadio.app.domain.metadata.MetadataRepository
1217
import com.vaultstadio.app.domain.metadata.model.DocumentMetadata
@@ -15,8 +20,10 @@ import com.vaultstadio.app.domain.metadata.model.ImageMetadata
1520
import com.vaultstadio.app.domain.metadata.model.MetadataSearchResult
1621
import com.vaultstadio.app.domain.metadata.model.VideoMetadata
1722
import com.vaultstadio.app.domain.result.Result
23+
import com.vaultstadio.app.domain.storage.model.ItemType
1824
import com.vaultstadio.app.domain.storage.model.PaginatedResponse
1925
import com.vaultstadio.app.domain.storage.model.StorageItem
26+
import com.vaultstadio.app.domain.storage.model.Visibility
2027
import kotlinx.coroutines.test.runTest
2128
import kotlinx.datetime.Instant
2229
import kotlin.test.Test
@@ -50,21 +57,50 @@ private fun testFileMetadata(
5057
extractedBy = emptyList(),
5158
)
5259

60+
private fun testStorageItem(
61+
id: String = "item-1",
62+
name: String = "doc.pdf",
63+
) = StorageItem(
64+
id = id,
65+
name = name,
66+
path = "/docs/$name",
67+
type = ItemType.FILE,
68+
parentId = "parent-1",
69+
size = 1024L,
70+
mimeType = "application/pdf",
71+
visibility = Visibility.PRIVATE,
72+
isStarred = false,
73+
isTrashed = false,
74+
createdAt = testInstant,
75+
updatedAt = testInstant,
76+
metadata = null,
77+
)
78+
5379
private class FakeMetadataRepository(
5480
var getSearchSuggestionsResult: Result<List<String>> = Result.success(emptyList()),
5581
var searchByMetadataResult: Result<PaginatedResponse<MetadataSearchResult>> = Result.success(
5682
PaginatedResponse(emptyList(), 0L, 0, 50, 0, false),
5783
),
5884
var getFileMetadataResult: Result<FileMetadata> = Result.success(testFileMetadata()),
85+
var advancedSearchResult: Result<PaginatedResponse<StorageItem>> = Result.success(
86+
PaginatedResponse(emptyList(), 0L, 0, 50, 0, false),
87+
),
88+
var getImageMetadataResult: Result<ImageMetadata> = Result.success(ImageMetadata(width = 1920, height = 1080)),
89+
var getVideoMetadataResult: Result<VideoMetadata> = Result.success(
90+
VideoMetadata(width = 1280, height = 720, duration = 120.0),
91+
),
92+
var getDocumentMetadataResult: Result<DocumentMetadata> = Result.success(
93+
DocumentMetadata(title = "Test", author = "Author", pageCount = 10),
94+
),
5995
) : MetadataRepository {
6096

6197
override suspend fun getFileMetadata(itemId: String): Result<FileMetadata> = getFileMetadataResult
6298

63-
override suspend fun getImageMetadata(itemId: String): Result<ImageMetadata> = stubResult()
99+
override suspend fun getImageMetadata(itemId: String): Result<ImageMetadata> = getImageMetadataResult
64100

65-
override suspend fun getVideoMetadata(itemId: String): Result<VideoMetadata> = stubResult()
101+
override suspend fun getVideoMetadata(itemId: String): Result<VideoMetadata> = getVideoMetadataResult
66102

67-
override suspend fun getDocumentMetadata(itemId: String): Result<DocumentMetadata> = stubResult()
103+
override suspend fun getDocumentMetadata(itemId: String): Result<DocumentMetadata> = getDocumentMetadataResult
68104

69105
override suspend fun advancedSearch(
70106
query: String,
@@ -76,7 +112,7 @@ private class FakeMetadataRepository(
76112
toDate: Instant?,
77113
limit: Int,
78114
offset: Int,
79-
): Result<PaginatedResponse<StorageItem>> = stubResult()
115+
): Result<PaginatedResponse<StorageItem>> = advancedSearchResult
80116

81117
override suspend fun searchByMetadata(
82118
key: String,
@@ -157,3 +193,96 @@ class GetFileMetadataUseCaseTest {
157193
assertNull(result.getOrNull())
158194
}
159195
}
196+
197+
class AdvancedSearchUseCaseTest {
198+
199+
@Test
200+
fun invoke_returnsRepositoryAdvancedSearchResult() = runTest {
201+
val items = listOf(testStorageItem("i1", "photo.jpg"), testStorageItem("i2", "doc.pdf"))
202+
val paged = PaginatedResponse(items, 2L, 0, 50, 2, false)
203+
val repo = FakeMetadataRepository(advancedSearchResult = Result.success(paged))
204+
val useCase = AdvancedSearchUseCaseImpl(repo)
205+
val result = useCase("query", searchContent = true, fileTypes = listOf("image"), limit = 50, offset = 0)
206+
assertTrue(result.isSuccess())
207+
assertEquals(2, result.getOrNull()?.items?.size)
208+
assertEquals("photo.jpg", result.getOrNull()?.items?.get(0)?.name)
209+
}
210+
211+
@Test
212+
fun invoke_propagatesError() = runTest {
213+
val repo = FakeMetadataRepository(advancedSearchResult = Result.error("UNAUTHORIZED", "Not logged in"))
214+
val useCase = AdvancedSearchUseCaseImpl(repo)
215+
val result = useCase("q")
216+
assertTrue(result.isError())
217+
assertNull(result.getOrNull())
218+
}
219+
}
220+
221+
class GetImageMetadataUseCaseTest {
222+
223+
@Test
224+
fun invoke_returnsRepositoryGetImageMetadataResult() = runTest {
225+
val meta = ImageMetadata(width = 1920, height = 1080, cameraMake = "Canon", cameraModel = "EOS R5")
226+
val repo = FakeMetadataRepository(getImageMetadataResult = Result.success(meta))
227+
val useCase = GetImageMetadataUseCaseImpl(repo)
228+
val result = useCase("item-1")
229+
assertTrue(result.isSuccess())
230+
assertEquals(1920, result.getOrNull()?.width)
231+
assertEquals("Canon", result.getOrNull()?.cameraMake)
232+
}
233+
234+
@Test
235+
fun invoke_propagatesError() = runTest {
236+
val repo = FakeMetadataRepository(getImageMetadataResult = Result.error("NOT_FOUND", "Item not found"))
237+
val useCase = GetImageMetadataUseCaseImpl(repo)
238+
val result = useCase("missing")
239+
assertTrue(result.isError())
240+
assertNull(result.getOrNull())
241+
}
242+
}
243+
244+
class GetVideoMetadataUseCaseTest {
245+
246+
@Test
247+
fun invoke_returnsRepositoryGetVideoMetadataResult() = runTest {
248+
val meta = VideoMetadata(width = 1920, height = 1080, duration = 90.5, title = "Clip")
249+
val repo = FakeMetadataRepository(getVideoMetadataResult = Result.success(meta))
250+
val useCase = GetVideoMetadataUseCaseImpl(repo)
251+
val result = useCase("video-1")
252+
assertTrue(result.isSuccess())
253+
assertEquals(1920, result.getOrNull()?.width)
254+
assertEquals(90.5, result.getOrNull()?.duration)
255+
}
256+
257+
@Test
258+
fun invoke_propagatesError() = runTest {
259+
val repo = FakeMetadataRepository(getVideoMetadataResult = Result.error("NOT_FOUND", "Item not found"))
260+
val useCase = GetVideoMetadataUseCaseImpl(repo)
261+
val result = useCase("missing")
262+
assertTrue(result.isError())
263+
assertNull(result.getOrNull())
264+
}
265+
}
266+
267+
class GetDocumentMetadataUseCaseTest {
268+
269+
@Test
270+
fun invoke_returnsRepositoryGetDocumentMetadataResult() = runTest {
271+
val meta = DocumentMetadata(title = "Report", author = "Jane", pageCount = 42, wordCount = 5000)
272+
val repo = FakeMetadataRepository(getDocumentMetadataResult = Result.success(meta))
273+
val useCase = GetDocumentMetadataUseCaseImpl(repo)
274+
val result = useCase("doc-1")
275+
assertTrue(result.isSuccess())
276+
assertEquals("Report", result.getOrNull()?.title)
277+
assertEquals(42, result.getOrNull()?.pageCount)
278+
}
279+
280+
@Test
281+
fun invoke_propagatesError() = runTest {
282+
val repo = FakeMetadataRepository(getDocumentMetadataResult = Result.error("NOT_FOUND", "Item not found"))
283+
val useCase = GetDocumentMetadataUseCaseImpl(repo)
284+
val result = useCase("missing")
285+
assertTrue(result.isError())
286+
assertNull(result.getOrNull())
287+
}
288+
}

0 commit comments

Comments
 (0)